feat(cli): split commit and release into target-based workflows

This commit is contained in:
2026-05-10 10:01:09 +00:00
parent 738fbaa64f
commit 0e27d54ad2
22 changed files with 1938 additions and 1057 deletions
+8
View File
@@ -51,6 +51,14 @@ export let run = async () => {
await modCommit.run(argvArg);
});
/**
* create a release from pending changelog entries
*/
gitzoneSmartcli.addCommand("release").subscribe(async (argvArg) => {
const modRelease = await import("./mod_release/index.js");
await modRelease.run(argvArg);
});
/**
* deprecate a package on npm
*/
+165
View File
@@ -0,0 +1,165 @@
import * as plugins from "./plugins.js";
export type TChangelogBucket =
| "Breaking Changes"
| "Features"
| "Fixes"
| "Documentation"
| "Maintenance";
export interface IChangelogEntry {
type: string;
scope: string;
message: string;
details?: string[];
}
export interface IPendingChangelog {
block: string;
isEmpty: boolean;
}
const bucketForCommitType = (commitType: string): TChangelogBucket => {
switch (commitType) {
case "BREAKING CHANGE":
return "Breaking Changes";
case "feat":
return "Features";
case "fix":
return "Fixes";
case "docs":
return "Documentation";
default:
return "Maintenance";
}
};
const readChangelog = async (filePath: string): Promise<string> => {
if (!(await plugins.smartfs.file(filePath).exists())) {
return "# Changelog\n\n";
}
return (await plugins.smartfs.file(filePath).encoding("utf8").read()) as string;
};
const writeChangelog = async (filePath: string, content: string): Promise<void> => {
await plugins.smartfs.file(filePath).encoding("utf8").write(content.endsWith("\n") ? content : `${content}\n`);
};
const findPendingSection = (
content: string,
sectionName: string,
): { start: number; bodyStart: number; end: number } | null => {
const headingRegex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
const match = headingRegex.exec(content);
if (!match || match.index === undefined) {
return null;
}
const bodyStart = match.index + match[0].length;
const rest = content.slice(bodyStart);
const nextHeadingMatch = /^##\s+/m.exec(rest);
const end = nextHeadingMatch ? bodyStart + nextHeadingMatch.index : content.length;
return { start: match.index, bodyStart, end };
};
export const ensurePendingSection = async (
filePath: string,
sectionName = "Pending",
): Promise<string> => {
let content = await readChangelog(filePath);
if (findPendingSection(content, sectionName)) {
return content;
}
const pendingSection = `## ${sectionName}\n\n`;
const titleMatch = /^#\s+.+$/m.exec(content);
if (titleMatch && titleMatch.index !== undefined) {
const insertAt = titleMatch.index + titleMatch[0].length;
content = `${content.slice(0, insertAt)}\n\n${pendingSection}${content.slice(insertAt).replace(/^\n+/, "")}`;
} else {
content = `# Changelog\n\n${pendingSection}${content}`;
}
await writeChangelog(filePath, content);
return content;
};
export const appendPendingChangelogEntry = async (
filePath: string,
sectionName: string,
entry: IChangelogEntry,
): Promise<void> => {
let content = await ensurePendingSection(filePath, sectionName);
const pendingSection = findPendingSection(content, sectionName)!;
let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end);
const bucket = bucketForCommitType(entry.type);
const bucketHeading = `### ${bucket}`;
const entryLines = [`- ${entry.message}${entry.scope ? ` (${entry.scope})` : ""}`];
for (const detail of entry.details || []) {
entryLines.push(` - ${detail}`);
}
const renderedEntry = entryLines.join("\n");
const bucketRegex = new RegExp(`^###\\s+${bucket.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
const bucketMatch = bucketRegex.exec(pendingBody);
if (!bucketMatch || bucketMatch.index === undefined) {
pendingBody = `${pendingBody.trimEnd()}\n\n${bucketHeading}\n\n${renderedEntry}\n`;
} else {
const bucketBodyStart = bucketMatch.index + bucketMatch[0].length;
const afterBucket = pendingBody.slice(bucketBodyStart);
const nextBucketMatch = /^###\s+/m.exec(afterBucket);
const insertAt = nextBucketMatch ? bucketBodyStart + nextBucketMatch.index : pendingBody.length;
const beforeInsert = pendingBody.slice(0, insertAt).trimEnd();
const afterInsert = pendingBody.slice(insertAt).replace(/^\n+/, "");
pendingBody = `${beforeInsert}\n${renderedEntry}\n\n${afterInsert}`;
}
content = `${content.slice(0, pendingSection.bodyStart)}\n${pendingBody.trim()}\n\n${content.slice(pendingSection.end).replace(/^\n+/, "")}`;
await writeChangelog(filePath, content);
};
export const readPendingChangelog = async (
filePath: string,
sectionName = "Pending",
): Promise<IPendingChangelog> => {
const content = await ensurePendingSection(filePath, sectionName);
const pendingSection = findPendingSection(content, sectionName)!;
const block = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
return {
block,
isEmpty: block.length === 0,
};
};
export const inferVersionTypeFromPending = (pendingBlock: string): "patch" | "minor" | "major" => {
if (/^###\s+Breaking Changes\s*$/m.test(pendingBlock)) {
return "major";
}
if (/^###\s+Features\s*$/m.test(pendingBlock)) {
return "minor";
}
return "patch";
};
export const movePendingToVersion = async (
filePath: string,
sectionName: string,
versionHeading: string,
version: string,
dateString: string,
): Promise<void> => {
let content = await ensurePendingSection(filePath, sectionName);
const pendingSection = findPendingSection(content, sectionName)!;
const pendingBlock = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
if (!pendingBlock) {
throw new Error("No pending changelog entries. Nothing to release.");
}
const renderedHeading = versionHeading
.replaceAll("{{version}}", version)
.replaceAll("{{date}}", dateString);
const nextContent = content.slice(pendingSection.end).replace(/^\n+/, "");
content = `${content.slice(0, pendingSection.bodyStart)}\n\n${renderedHeading}\n\n${pendingBlock}\n\n${nextContent}`;
await writeChangelog(filePath, content);
};
+192
View File
@@ -0,0 +1,192 @@
export const CURRENT_GITZONE_CLI_SCHEMA_VERSION = 2;
export interface ISmartconfigMigrationResult {
migrated: boolean;
fromVersion: number;
toVersion: number;
}
const CLI_NAMESPACE = "@git.zone/cli";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
const ensureObject = (parent: Record<string, any>, key: string): Record<string, any> => {
if (!isPlainObject(parent[key])) {
parent[key] = {};
}
return parent[key];
};
const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => {
let migrated = false;
const migrations = [
{ oldKey: "gitzone", newKey: CLI_NAMESPACE },
{ oldKey: "tsdoc", newKey: "@git.zone/tsdoc" },
{ oldKey: "npmdocker", newKey: "@git.zone/tsdocker" },
{ oldKey: "npmci", newKey: "@ship.zone/szci" },
{ oldKey: "szci", newKey: "@ship.zone/szci" },
];
for (const { oldKey, newKey } of migrations) {
if (!isPlainObject(smartconfigJson[oldKey])) {
continue;
}
if (!isPlainObject(smartconfigJson[newKey])) {
smartconfigJson[newKey] = smartconfigJson[oldKey];
} else {
smartconfigJson[newKey] = {
...smartconfigJson[oldKey],
...smartconfigJson[newKey],
};
}
delete smartconfigJson[oldKey];
migrated = true;
}
return migrated;
};
const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
const releaseConfig = ensureObject(cliConfig, "release");
let migrated = false;
const targets = ensureObject(releaseConfig, "targets");
const shipzoneConfig = smartconfigJson["@ship.zone/szci"];
if (isPlainObject(releaseConfig.git) && !isPlainObject(targets.git)) {
targets.git = releaseConfig.git;
delete releaseConfig.git;
migrated = true;
}
if (isPlainObject(releaseConfig.npm) && !isPlainObject(targets.npm)) {
targets.npm = releaseConfig.npm;
delete releaseConfig.npm;
migrated = true;
}
if (isPlainObject(releaseConfig.docker) && !isPlainObject(targets.docker)) {
targets.docker = releaseConfig.docker;
delete releaseConfig.docker;
migrated = true;
}
if (Array.isArray(releaseConfig.registries)) {
const npmTarget = ensureObject(targets, "npm");
if (!Array.isArray(npmTarget.registries)) {
npmTarget.registries = releaseConfig.registries;
}
delete releaseConfig.registries;
migrated = true;
}
if (releaseConfig.accessLevel) {
const npmTarget = ensureObject(targets, "npm");
if (!npmTarget.accessLevel) {
npmTarget.accessLevel = releaseConfig.accessLevel;
}
delete releaseConfig.accessLevel;
migrated = true;
}
if (isPlainObject(shipzoneConfig)) {
if (shipzoneConfig.npmAccessLevel) {
const npmTarget = ensureObject(targets, "npm");
if (!npmTarget.accessLevel) {
npmTarget.accessLevel = shipzoneConfig.npmAccessLevel;
}
delete shipzoneConfig.npmAccessLevel;
migrated = true;
}
if (shipzoneConfig.npmRegistryUrl) {
const npmTarget = ensureObject(targets, "npm");
const registry = normalizeRegistryUrl(shipzoneConfig.npmRegistryUrl);
const registries = Array.isArray(npmTarget.registries) ? npmTarget.registries : [];
if (!registries.includes(registry)) {
registries.push(registry);
}
npmTarget.registries = registries;
delete shipzoneConfig.npmRegistryUrl;
migrated = true;
}
}
if (Array.isArray(releaseConfig.steps)) {
const steps = releaseConfig.steps as string[];
const preflight = ensureObject(releaseConfig, "preflight");
if (steps.includes("test") && preflight.test === undefined) {
preflight.test = true;
}
if (steps.includes("build") && preflight.build === undefined) {
preflight.build = true;
}
if (steps.includes("push")) {
const gitTarget = ensureObject(targets, "git");
if (gitTarget.enabled === undefined) {
gitTarget.enabled = true;
}
}
if (steps.includes("publishNpm")) {
const npmTarget = ensureObject(targets, "npm");
if (npmTarget.enabled === undefined) {
npmTarget.enabled = true;
}
}
if (steps.includes("publishDocker")) {
const dockerTarget = ensureObject(targets, "docker");
if (dockerTarget.enabled === undefined) {
dockerTarget.enabled = true;
}
}
delete releaseConfig.steps;
migrated = true;
}
if (releaseConfig.changelog) {
delete releaseConfig.changelog;
migrated = true;
}
cliConfig.schemaVersion = 2;
return migrated || true;
};
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;
};
export const migrateSmartconfigData = (
smartconfigJson: Record<string, any>,
targetVersion = CURRENT_GITZONE_CLI_SCHEMA_VERSION,
): ISmartconfigMigrationResult => {
let migrated = false;
migrated = migrateNamespaceKeys(smartconfigJson) || migrated;
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1;
let currentVersion = fromVersion;
if (currentVersion < 2 && targetVersion >= 2) {
migrated = migrateToV2(smartconfigJson) || migrated;
currentVersion = 2;
}
if (targetVersion === CURRENT_GITZONE_CLI_SCHEMA_VERSION && cliConfig.schemaVersion !== targetVersion) {
cliConfig.schemaVersion = targetVersion;
migrated = true;
}
return {
migrated,
fromVersion,
toVersion: Math.min(targetVersion, CURRENT_GITZONE_CLI_SCHEMA_VERSION),
};
};
+387
View File
@@ -0,0 +1,387 @@
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 || [],
};
};
+249 -451
View File
@@ -3,15 +3,11 @@
import * as plugins from "./mod.plugins.js";
import * as paths from "../paths.js";
import { logger } from "../gitzone.logging.js";
import * as helpers from "./mod.helpers.js";
import * as ui from "./mod.ui.js";
import { ReleaseConfig } from "../mod_config/classes.releaseconfig.js";
import type { ICliMode } from "../helpers.climode.js";
import {
getCliMode,
printJson,
runWithSuppressedOutput,
} from "../helpers.climode.js";
import { getCliMode, printJson, runWithSuppressedOutput } from "../helpers.climode.js";
import { appendPendingChangelogEntry } from "../helpers.changelog.js";
import { resolveCommitWorkflow, type IResolvedCommitWorkflow } from "../helpers.workflow.js";
export const run = async (argvArg: any) => {
const mode = await getCliMode(argvArg);
@@ -36,431 +32,247 @@ export const run = async (argvArg: any) => {
return;
}
// Read commit config from .smartconfig.json
const smartconfigInstance = new plugins.smartconfig.Smartconfig();
const gitzoneConfig = smartconfigInstance.dataFor<{
commit?: {
alwaysTest?: boolean;
alwaysBuild?: boolean;
};
}>("@git.zone/cli", {});
const commitConfig = gitzoneConfig.commit || {};
// Check flags and merge with config options
const wantsRelease = !!(argvArg.r || argvArg.release);
const wantsTest = !!(argvArg.t || argvArg.test || commitConfig.alwaysTest);
const wantsBuild = !!(argvArg.b || argvArg.build || commitConfig.alwaysBuild);
let releaseConfig: ReleaseConfig | null = null;
if (wantsRelease) {
releaseConfig = await ReleaseConfig.fromCwd();
if (!releaseConfig.hasRegistries()) {
logger.log("error", "No release registries configured.");
console.log("");
console.log(
" Run `gitzone config add <registry-url>` to add registries.",
);
console.log("");
process.exit(1);
}
const workflow = await resolveCommitWorkflow(argvArg);
if (workflow.releaseFlagRequested) {
logger.log(
"warn",
"`gitzone commit -r` is deprecated and no longer releases. Use `gitzone release` after committing.",
);
}
// Print execution plan at the start
ui.printExecutionPlan({
autoAccept: !!(argvArg.y || argvArg.yes),
push: !!(argvArg.p || argvArg.push),
test: wantsTest,
build: wantsBuild,
release: wantsRelease,
format: !!argvArg.format,
registries: releaseConfig?.getRegistries(),
});
if (argvArg.format) {
const formatMod = await import("../mod_format/index.js");
await formatMod.run();
printCommitExecutionPlan(workflow);
if (workflow.confirmation === "plan") {
return;
}
// Run tests early to fail fast before analysis
if (wantsTest) {
ui.printHeader("🧪 Running tests...");
const smartshellForTest = new plugins.smartshell.Smartshell({
executor: "bash",
sourceFilePaths: [],
});
const testResult = await smartshellForTest.exec("pnpm test");
if (testResult.exitCode !== 0) {
logger.log("error", "Tests failed. Aborting commit.");
process.exit(1);
}
logger.log("success", "All tests passed.");
}
ui.printHeader("🔍 Analyzing repository changes...");
const aidoc = new plugins.tsdoc.AiDoc();
await aidoc.start();
const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd);
await aidoc.stop();
ui.printRecommendation({
recommendedNextVersion: nextCommitObject.recommendedNextVersion,
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
recommendedNextVersionMessage:
nextCommitObject.recommendedNextVersionMessage,
});
let answerBucket: plugins.smartinteract.AnswerBucket;
// Check if -y/--yes flag is set AND version is not a breaking change
// Breaking changes (major version bumps) always require manual confirmation
const isBreakingChange =
nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE";
const canAutoAccept = (argvArg.y || argvArg.yes) && !isBreakingChange;
if (canAutoAccept) {
// Auto-mode: create AnswerBucket programmatically
logger.log("info", "✓ Auto-accepting AI recommendations (--yes flag)");
answerBucket = new plugins.smartinteract.AnswerBucket();
answerBucket.addAnswer({
name: "commitType",
value: nextCommitObject.recommendedNextVersionLevel,
});
answerBucket.addAnswer({
name: "commitScope",
value: nextCommitObject.recommendedNextVersionScope,
});
answerBucket.addAnswer({
name: "commitDescription",
value: nextCommitObject.recommendedNextVersionMessage,
});
answerBucket.addAnswer({
name: "pushToOrigin",
value: !!(argvArg.p || argvArg.push), // Only push if -p flag also provided
});
answerBucket.addAnswer({
name: "createRelease",
value: wantsRelease,
});
} else {
// Warn if --yes was provided but we're requiring confirmation due to breaking change
if (isBreakingChange && (argvArg.y || argvArg.yes)) {
logger.log(
"warn",
"⚠️ BREAKING CHANGE detected - manual confirmation required",
);
}
// Interactive mode: prompt user for input
const commitInteract = new plugins.smartinteract.SmartInteract();
commitInteract.addQuestions([
{
type: "list",
name: `commitType`,
message: `Choose TYPE of the commit:`,
choices: [`fix`, `feat`, `BREAKING CHANGE`],
default: nextCommitObject.recommendedNextVersionLevel,
},
{
type: "input",
name: `commitScope`,
message: `What is the SCOPE of the commit:`,
default: nextCommitObject.recommendedNextVersionScope,
},
{
type: `input`,
name: `commitDescription`,
message: `What is the DESCRIPTION of the commit?`,
default: nextCommitObject.recommendedNextVersionMessage,
},
{
type: "confirm",
name: `pushToOrigin`,
message: `Do you want to push this version now?`,
default: true,
},
{
type: "confirm",
name: `createRelease`,
message: `Do you want to publish to npm registries?`,
default: wantsRelease,
},
]);
answerBucket = await commitInteract.runQueue();
}
const commitString = createCommitStringFromAnswerBucket(answerBucket);
const commitType = answerBucket.getAnswerFor("commitType");
let commitVersionType: helpers.VersionType;
switch (commitType) {
case "fix":
commitVersionType = "patch";
break;
case "feat":
commitVersionType = "minor";
break;
case "BREAKING CHANGE":
commitVersionType = "major";
break;
default:
throw new Error(`Unsupported commit type: ${commitType}`);
}
ui.printHeader("✨ Creating Semantic Commit");
ui.printCommitMessage(commitString);
const smartshellInstance = new plugins.smartshell.Smartshell({
executor: "bash",
sourceFilePaths: [],
});
// Load release config if user wants to release (interactively selected)
if (answerBucket.getAnswerFor("createRelease") && !releaseConfig) {
releaseConfig = await ReleaseConfig.fromCwd();
if (!releaseConfig.hasRegistries()) {
logger.log("error", "No release registries configured.");
console.log("");
console.log(
" Run `gitzone config add <registry-url>` to add registries.",
);
console.log("");
process.exit(1);
let nextCommitObject: any;
let answerBucket: plugins.smartinteract.AnswerBucket | undefined;
for (const step of workflow.steps) {
switch (step) {
case "format":
await runFormatStep();
break;
case "test":
await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand);
break;
case "build":
await runCommandStep(smartshellInstance, "Running build", workflow.buildCommand);
break;
case "analyze":
nextCommitObject = await runAnalyzeStep();
answerBucket = await buildAnswerBucket(nextCommitObject, workflow, mode, argvArg);
break;
case "changelog":
assertAnalysisComplete(answerBucket, nextCommitObject);
await runChangelogStep(workflow, answerBucket!, nextCommitObject);
break;
case "commit":
assertAnalysisComplete(answerBucket, nextCommitObject);
await runCommitStep(smartshellInstance, answerBucket!);
break;
case "push":
await runPushStep(smartshellInstance, workflow);
break;
}
}
// Determine total steps based on options
// Note: test runs early (like format) so not counted in numbered steps
const willPush =
answerBucket.getAnswerFor("pushToOrigin") && !(process.env.CI === "true");
const willRelease =
answerBucket.getAnswerFor("createRelease") &&
releaseConfig?.hasRegistries();
let totalSteps = 5; // Base steps: commitinfo, changelog, staging, commit, version
if (wantsBuild) totalSteps += 2; // build step + verification step
if (willPush) totalSteps++;
if (willRelease) totalSteps++;
let currentStep = 0;
// Step 1: Baking commitinfo
currentStep++;
ui.printStep(
currentStep,
totalSteps,
"🔧 Baking commit info into code",
"in-progress",
);
const commitInfo = new plugins.commitinfo.CommitInfo(
paths.cwd,
commitVersionType,
);
await commitInfo.writeIntoPotentialDirs();
ui.printStep(
currentStep,
totalSteps,
"🔧 Baking commit info into code",
"done",
);
// Step 2: Writing changelog
currentStep++;
ui.printStep(
currentStep,
totalSteps,
"📄 Generating changelog.md",
"in-progress",
);
let changelog = nextCommitObject.changelog || "# Changelog\n";
changelog = changelog.replaceAll(
"{{nextVersion}}",
(await commitInfo.getNextPlannedVersion()).versionString,
);
changelog = changelog.replaceAll(
"{{nextVersionScope}}",
`${await answerBucket.getAnswerFor("commitType")}(${await answerBucket.getAnswerFor("commitScope")})`,
);
changelog = changelog.replaceAll(
"{{nextVersionMessage}}",
nextCommitObject.recommendedNextVersionMessage,
);
if (nextCommitObject.recommendedNextVersionDetails?.length > 0) {
changelog = changelog.replaceAll(
"{{nextVersionDetails}}",
"- " + nextCommitObject.recommendedNextVersionDetails.join("\n- "),
);
} else {
changelog = changelog.replaceAll("\n{{nextVersionDetails}}", "");
}
await plugins.smartfs
.file(plugins.path.join(paths.cwd, `changelog.md`))
.encoding("utf8")
.write(changelog);
ui.printStep(currentStep, totalSteps, "📄 Generating changelog.md", "done");
// Step 3: Staging files
currentStep++;
ui.printStep(currentStep, totalSteps, "📦 Staging files", "in-progress");
await smartshellInstance.exec(`git add -A`);
ui.printStep(currentStep, totalSteps, "📦 Staging files", "done");
// Step 4: Creating commit
currentStep++;
ui.printStep(
currentStep,
totalSteps,
"💾 Creating git commit",
"in-progress",
);
await smartshellInstance.exec(`git commit -m "${commitString}"`);
ui.printStep(currentStep, totalSteps, "💾 Creating git commit", "done");
// Step 5: Bumping version
currentStep++;
const projectType = await helpers.detectProjectType();
const newVersion = await helpers.bumpProjectVersion(
projectType,
commitVersionType,
currentStep,
totalSteps,
);
// Step 6: Run build (optional)
if (wantsBuild) {
currentStep++;
ui.printStep(currentStep, totalSteps, "🔨 Running build", "in-progress");
const buildResult = await smartshellInstance.exec("pnpm build");
if (buildResult.exitCode !== 0) {
ui.printStep(currentStep, totalSteps, "🔨 Running build", "error");
logger.log("error", "Build failed. Aborting release.");
process.exit(1);
}
ui.printStep(currentStep, totalSteps, "🔨 Running build", "done");
// Step 7: Verify no uncommitted changes
currentStep++;
ui.printStep(
currentStep,
totalSteps,
"🔍 Verifying clean working tree",
"in-progress",
);
const statusResult = await smartshellInstance.exec(
"git status --porcelain",
);
if (statusResult.stdout.trim() !== "") {
ui.printStep(
currentStep,
totalSteps,
"🔍 Verifying clean working tree",
"error",
);
logger.log(
"error",
"Build produced uncommitted changes. This usually means build output is not gitignored.",
);
logger.log("error", "Uncommitted files:");
console.log(statusResult.stdout);
logger.log(
"error",
"Aborting release. Please ensure build artifacts are in .gitignore",
);
process.exit(1);
}
ui.printStep(
currentStep,
totalSteps,
"🔍 Verifying clean working tree",
"done",
);
}
// Step: Push to remote (optional)
const currentBranch = await helpers.detectCurrentBranch();
if (willPush) {
currentStep++;
ui.printStep(
currentStep,
totalSteps,
`🚀 Pushing to origin/${currentBranch}`,
"in-progress",
);
await smartshellInstance.exec(
`git push origin ${currentBranch} --follow-tags`,
);
ui.printStep(
currentStep,
totalSteps,
`🚀 Pushing to origin/${currentBranch}`,
"done",
);
}
// Step 7: Publish to npm registries (optional)
let releasedRegistries: string[] = [];
if (willRelease && releaseConfig) {
currentStep++;
const registries = releaseConfig.getRegistries();
ui.printStep(
currentStep,
totalSteps,
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
"in-progress",
);
const accessLevel = releaseConfig.getAccessLevel();
for (const registry of registries) {
try {
await smartshellInstance.exec(
`npm publish --registry=${registry} --access=${accessLevel}`,
);
releasedRegistries.push(registry);
} catch (error) {
logger.log("error", `Failed to publish to ${registry}: ${error}`);
}
}
if (releasedRegistries.length === registries.length) {
ui.printStep(
currentStep,
totalSteps,
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
"done",
);
} else {
ui.printStep(
currentStep,
totalSteps,
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
"error",
);
}
}
console.log(""); // Add spacing before summary
// Get commit SHA for summary
const commitShaResult = await smartshellInstance.exec(
"git rev-parse --short HEAD",
);
const commitSha = commitShaResult.stdout.trim();
// Print final summary
const commitShaResult = await smartshellInstance.exec("git rev-parse --short HEAD");
const currentBranch = await detectCurrentBranch(smartshellInstance);
ui.printSummary({
projectType,
projectType: "source",
branch: currentBranch,
commitType: answerBucket.getAnswerFor("commitType"),
commitScope: answerBucket.getAnswerFor("commitScope"),
commitMessage: answerBucket.getAnswerFor("commitDescription"),
newVersion: newVersion,
commitSha: commitSha,
pushed: willPush,
released: releasedRegistries.length > 0,
releasedRegistries:
releasedRegistries.length > 0 ? releasedRegistries : undefined,
commitType: answerBucket!.getAnswerFor("commitType"),
commitScope: answerBucket!.getAnswerFor("commitScope"),
commitMessage: answerBucket!.getAnswerFor("commitDescription"),
commitSha: commitShaResult.stdout.trim(),
pushed: workflow.steps.includes("push"),
});
};
async function runFormatStep(): Promise<void> {
ui.printHeader("Formatting project files");
const formatMod = await import("../mod_format/index.js");
await formatMod.run({ write: true, yes: true, interactive: false });
}
async function runCommandStep(
smartshellInstance: plugins.smartshell.Smartshell,
label: string,
command: string,
): Promise<void> {
ui.printHeader(label);
const result = await smartshellInstance.exec(command);
if (result.exitCode !== 0) {
logger.log("error", `${label} failed. Aborting commit.`);
process.exit(1);
}
logger.log("success", `${label} passed.`);
}
async function runAnalyzeStep(): Promise<any> {
ui.printHeader("Analyzing repository changes");
const aidoc = new plugins.tsdoc.AiDoc();
await aidoc.start();
try {
const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd);
ui.printRecommendation({
recommendedNextVersion: nextCommitObject.recommendedNextVersion,
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage,
});
return nextCommitObject;
} finally {
await aidoc.stop();
}
}
async function buildAnswerBucket(
nextCommitObject: any,
workflow: IResolvedCommitWorkflow,
mode: ICliMode,
argvArg: any,
): Promise<plugins.smartinteract.AnswerBucket> {
const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE";
const canAutoAccept = workflow.confirmation === "auto" && !isBreakingChange;
if (canAutoAccept) {
logger.log("info", "Auto-accepting AI recommendations");
return createAnswerBucket({
commitType: nextCommitObject.recommendedNextVersionLevel,
commitScope: nextCommitObject.recommendedNextVersionScope,
commitDescription: nextCommitObject.recommendedNextVersionMessage,
});
}
if (isBreakingChange && (workflow.confirmation === "auto" || argvArg.y || argvArg.yes)) {
logger.log("warn", "BREAKING CHANGE detected - manual confirmation required");
}
if (!mode.interactive) {
throw new Error("Commit confirmation requires an interactive terminal. Use `-y` or set commit.confirmation to `auto`.");
}
const commitInteract = new plugins.smartinteract.SmartInteract();
commitInteract.addQuestions([
{
type: "list",
name: "commitType",
message: "Choose TYPE of the commit:",
choices: ["fix", "feat", "BREAKING CHANGE"],
default: nextCommitObject.recommendedNextVersionLevel,
},
{
type: "input",
name: "commitScope",
message: "What is the SCOPE of the commit:",
default: nextCommitObject.recommendedNextVersionScope,
},
{
type: "input",
name: "commitDescription",
message: "What is the DESCRIPTION of the commit?",
default: nextCommitObject.recommendedNextVersionMessage,
},
]);
return await commitInteract.runQueue();
}
function createAnswerBucket(answers: {
commitType: string;
commitScope: string;
commitDescription: string;
}): plugins.smartinteract.AnswerBucket {
const answerBucket = new plugins.smartinteract.AnswerBucket();
for (const [name, value] of Object.entries(answers)) {
answerBucket.addAnswer({ name, value });
}
return answerBucket;
}
async function runChangelogStep(
workflow: IResolvedCommitWorkflow,
answerBucket: plugins.smartinteract.AnswerBucket,
nextCommitObject: any,
): Promise<void> {
await appendPendingChangelogEntry(
plugins.path.join(paths.cwd, workflow.changelogFile),
workflow.changelogSection,
{
type: answerBucket.getAnswerFor("commitType"),
scope: answerBucket.getAnswerFor("commitScope"),
message: answerBucket.getAnswerFor("commitDescription"),
details: nextCommitObject.recommendedNextVersionDetails || [],
},
);
logger.log("success", `Updated ${workflow.changelogFile} pending section.`);
}
async function runCommitStep(
smartshellInstance: plugins.smartshell.Smartshell,
answerBucket: plugins.smartinteract.AnswerBucket,
): Promise<void> {
ui.printHeader("Creating Semantic Commit");
const commitString = createCommitStringFromAnswerBucket(answerBucket);
ui.printCommitMessage(commitString);
await smartshellInstance.exec("git add -A");
const result = await smartshellInstance.exec(`git commit -m ${shellQuote(commitString)}`);
if (result.exitCode !== 0) {
logger.log("error", "git commit failed.");
process.exit(1);
}
}
async function runPushStep(
smartshellInstance: plugins.smartshell.Smartshell,
workflow: IResolvedCommitWorkflow,
): Promise<void> {
const currentBranch = await detectCurrentBranch(smartshellInstance);
const followTags = workflow.pushFollowTags ? " --follow-tags" : "";
const result = await smartshellInstance.exec(
`git push ${workflow.pushRemote} ${currentBranch}${followTags}`,
);
if (result.exitCode !== 0) {
logger.log("error", "git push failed.");
process.exit(1);
}
}
async function detectCurrentBranch(
smartshellInstance: plugins.smartshell.Smartshell,
): Promise<string> {
const branchResult = await smartshellInstance.exec("git branch --show-current");
return branchResult.stdout.trim() || "master";
}
function assertAnalysisComplete(
answerBucket: plugins.smartinteract.AnswerBucket | undefined,
nextCommitObject: any,
): void {
if (!answerBucket || !nextCommitObject) {
throw new Error("Commit workflow requires analyze before changelog and commit steps.");
}
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function printCommitExecutionPlan(workflow: IResolvedCommitWorkflow): void {
console.log("");
console.log("gitzone commit - resolved workflow");
console.log(`confirmation: ${workflow.confirmation}`);
console.log(`steps: ${workflow.steps.join(" -> ")}`);
console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogSection}`);
console.log("");
}
async function handleRecommend(mode: ICliMode): Promise<void> {
const recommendationBuilder = async () => {
const aidoc = new plugins.tsdoc.AiDoc();
@@ -507,40 +319,27 @@ export function showHelp(mode?: ICliMode): void {
printJson({
command: "commit",
usage: "gitzone commit [recommend] [options]",
description:
"Creates semantic commits or emits a read-only recommendation.",
description: "Analyzes changes and creates one semantic source commit.",
commands: [
{
name: "recommend",
description:
"Generate a commit recommendation without mutating the repository",
description: "Generate a commit recommendation without mutating the repository",
},
],
flags: [
{ flag: "-y, --yes", description: "Auto-accept AI recommendations" },
{ flag: "-p, --push", description: "Push to origin after commit" },
{ flag: "-t, --test", description: "Run tests before the commit flow" },
{
flag: "-b, --build",
description: "Run the build after the commit flow",
},
{
flag: "-r, --release",
description: "Publish to configured registries after push",
},
{
flag: "--format",
description: "Run gitzone format before committing",
},
{
flag: "--json",
description: "Emit JSON for `commit recommend` only",
},
{ flag: "-y, --yes", description: "Auto-accept safe AI recommendations" },
{ flag: "-p, --push", description: "Push to origin after committing" },
{ flag: "-t, --test", description: "Run tests as part of the commit workflow" },
{ flag: "-b, --build", description: "Run build as part of the commit workflow" },
{ flag: "-f, --format", description: "Run gitzone format before committing" },
{ flag: "--plan", description: "Show resolved workflow without mutating files" },
{ flag: "--json", description: "Emit JSON for `commit recommend` only" },
],
examples: [
"gitzone commit recommend --json",
"gitzone commit -y",
"gitzone commit -ypbr",
"gitzone commit -ytbp",
"gitzone release",
],
});
return;
@@ -549,25 +348,24 @@ export function showHelp(mode?: ICliMode): void {
console.log("");
console.log("Usage: gitzone commit [recommend] [options]");
console.log("");
console.log("Creates one semantic source commit. It does not version, tag, or publish.");
console.log("");
console.log("Commands:");
console.log(
" recommend Generate a commit recommendation without mutating the repository",
);
console.log(" recommend Generate a commit recommendation without mutating the repository");
console.log("");
console.log("Flags:");
console.log(" -y, --yes Auto-accept AI recommendations");
console.log(" -p, --push Push to origin after commit");
console.log(" -t, --test Run tests before the commit flow");
console.log(" -b, --build Run the build after the commit flow");
console.log(
" -r, --release Publish to configured registries after push",
);
console.log(" --format Run gitzone format before committing");
console.log(" -y, --yes Auto-accept safe AI recommendations");
console.log(" -p, --push Push after commit");
console.log(" -t, --test Run tests in the configured order");
console.log(" -b, --build Run build in the configured order");
console.log(" -f, --format Run gitzone format before committing");
console.log(" --plan Show resolved workflow without mutating files");
console.log(" --json Emit JSON for `commit recommend` only");
console.log("");
console.log("Examples:");
console.log(" gitzone commit recommend --json");
console.log(" gitzone commit -y");
console.log(" gitzone commit -ypbr");
console.log(" gitzone commit -ytbp");
console.log(" gitzone release");
console.log("");
}
+28 -16
View File
@@ -63,7 +63,7 @@ export async function detectProjectType(): Promise<ProjectType> {
* @param versionType Type of version bump
* @returns New version string
*/
function calculateNewVersion(currentVersion: string, versionType: VersionType): string {
export function calculateNewVersion(currentVersion: string, versionType: VersionType): string {
const versionMatch = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!versionMatch) {
@@ -95,7 +95,7 @@ function calculateNewVersion(currentVersion: string, versionType: VersionType):
* @param projectType The project type to determine which file to read
* @returns The current version string
*/
async function readCurrentVersion(projectType: ProjectType): Promise<string> {
export async function readCurrentVersion(projectType: ProjectType): Promise<string> {
if (projectType === 'npm' || projectType === 'both') {
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
const content = (await plugins.smartfs
@@ -128,7 +128,7 @@ async function readCurrentVersion(projectType: ProjectType): Promise<string> {
* @param filePath Path to the JSON file
* @param newVersion The new version to write
*/
async function updateVersionFile(filePath: string, newVersion: string): Promise<void> {
export async function updateVersionFile(filePath: string, newVersion: string): Promise<void> {
const content = (await plugins.smartfs
.file(filePath)
.encoding('utf8')
@@ -141,6 +141,30 @@ async function updateVersionFile(filePath: string, newVersion: string): Promise<
.write(JSON.stringify(config, null, 2) + '\n');
}
/**
* Updates project version files without creating commits or tags.
*/
export async function updateProjectVersionFiles(
projectType: ProjectType,
newVersion: string,
): Promise<string[]> {
const filesToUpdate: string[] = [];
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
const denoJsonPath = plugins.path.join(paths.cwd, 'deno.json');
if (projectType === 'npm' || projectType === 'both') {
await updateVersionFile(packageJsonPath, newVersion);
filesToUpdate.push('package.json');
}
if (projectType === 'deno' || projectType === 'both') {
await updateVersionFile(denoJsonPath, newVersion);
filesToUpdate.push('deno.json');
}
return filesToUpdate;
}
/**
* Bumps the project version based on project type
* Handles npm-only, deno-only, and dual projects with unified logic
@@ -182,19 +206,7 @@ export async function bumpProjectVersion(
logger.log('info', `Bumping version: ${currentVersion}${newVersion}`);
// 3. Determine which files to update
const filesToUpdate: string[] = [];
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
const denoJsonPath = plugins.path.join(paths.cwd, 'deno.json');
if (projectType === 'npm' || projectType === 'both') {
await updateVersionFile(packageJsonPath, newVersion);
filesToUpdate.push('package.json');
}
if (projectType === 'deno' || projectType === 'both') {
await updateVersionFile(denoJsonPath, newVersion);
filesToUpdate.push('deno.json');
}
const filesToUpdate = await updateProjectVersionFiles(projectType, newVersion);
// 4. Stage all updated files
await smartshellInstance.exec(`git add ${filesToUpdate.join(' ')}`);
+7 -2
View File
@@ -10,7 +10,7 @@ interface ICommitSummary {
commitType: string;
commitScope: string;
commitMessage: string;
newVersion: string;
newVersion?: string;
commitSha?: string;
pushed: boolean;
repoUrl?: string;
@@ -197,9 +197,14 @@ export function printSummary(summary: ICommitSummary): void {
`Branch: 🌿 ${summary.branch}`,
`Commit Type: ${getCommitTypeEmoji(summary.commitType)}`,
`Scope: 📍 ${summary.commitScope}`,
`New Version: 🏷️ v${summary.newVersion}`,
];
if (summary.newVersion) {
lines.push(`New Version: 🏷️ v${summary.newVersion}`);
} else {
lines.push(`Version: ⊘ Not bumped`);
}
if (summary.commitSha) {
lines.push(`Commit SHA: 📌 ${summary.commitSha}`);
}
+33 -3
View File
@@ -3,6 +3,8 @@ import * as plugins from './mod.plugins.js';
export interface ICommitConfig {
alwaysTest: boolean;
alwaysBuild: boolean;
confirmation: 'prompt' | 'auto' | 'plan';
steps: string[];
}
/**
@@ -15,7 +17,7 @@ export class CommitConfig {
constructor(cwd: string = process.cwd()) {
this.cwd = cwd;
this.config = { alwaysTest: false, alwaysBuild: false };
this.config = { alwaysTest: false, alwaysBuild: false, confirmation: 'prompt', steps: ['analyze', 'changelog', 'commit'] };
}
/**
@@ -34,9 +36,19 @@ export class CommitConfig {
const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
const alwaysTest = gitzoneConfig?.commit?.alwaysTest ?? false;
const alwaysBuild = gitzoneConfig?.commit?.alwaysBuild ?? false;
this.config = {
alwaysTest: gitzoneConfig?.commit?.alwaysTest ?? false,
alwaysBuild: gitzoneConfig?.commit?.alwaysBuild ?? false,
alwaysTest,
alwaysBuild,
confirmation: gitzoneConfig?.commit?.confirmation ?? 'prompt',
steps: gitzoneConfig?.commit?.steps || [
'analyze',
...(alwaysTest ? ['test'] : []),
...(alwaysBuild ? ['build'] : []),
'changelog',
'commit',
],
};
}
@@ -66,6 +78,8 @@ export class CommitConfig {
// Update commit settings
smartconfigData['@git.zone/cli'].commit.alwaysTest = this.config.alwaysTest;
smartconfigData['@git.zone/cli'].commit.alwaysBuild = this.config.alwaysBuild;
smartconfigData['@git.zone/cli'].commit.confirmation = this.config.confirmation;
smartconfigData['@git.zone/cli'].commit.steps = this.config.steps;
// Write back to file
await plugins.smartfs
@@ -101,4 +115,20 @@ export class CommitConfig {
public setAlwaysBuild(value: boolean): void {
this.config.alwaysBuild = value;
}
public getConfirmation(): 'prompt' | 'auto' | 'plan' {
return this.config.confirmation;
}
public setConfirmation(value: 'prompt' | 'auto' | 'plan'): void {
this.config.confirmation = value;
}
public getSteps(): string[] {
return [...this.config.steps];
}
public setSteps(steps: string[]): void {
this.config.steps = [...steps];
}
}
+14 -7
View File
@@ -35,13 +35,11 @@ export class ReleaseConfig {
public async load(): Promise<void> {
const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
// Also check szci for backward compatibility
const szciConfig = smartconfigInstance.dataFor<any>('@ship.zone/szci', {});
const npmTarget = gitzoneConfig?.release?.targets?.npm || {};
this.config = {
registries: gitzoneConfig?.release?.registries || [],
accessLevel: gitzoneConfig?.release?.accessLevel || szciConfig?.npmAccessLevel || 'public',
registries: npmTarget.registries || [],
accessLevel: npmTarget.accessLevel || 'public',
};
}
@@ -68,9 +66,18 @@ export class ReleaseConfig {
smartconfigData['@git.zone/cli'].release = {};
}
if (!smartconfigData['@git.zone/cli'].release.targets) {
smartconfigData['@git.zone/cli'].release.targets = {};
}
if (!smartconfigData['@git.zone/cli'].release.targets.npm) {
smartconfigData['@git.zone/cli'].release.targets.npm = {};
}
// Update registries and accessLevel
smartconfigData['@git.zone/cli'].release.registries = this.config.registries;
smartconfigData['@git.zone/cli'].release.accessLevel = this.config.accessLevel;
smartconfigData['@git.zone/cli'].release.targets.npm.enabled = this.config.registries.length > 0;
smartconfigData['@git.zone/cli'].release.targets.npm.registries = this.config.registries;
smartconfigData['@git.zone/cli'].release.targets.npm.accessLevel = this.config.accessLevel;
// Write back to file
await plugins.smartfs
+89 -28
View File
@@ -1,4 +1,4 @@
// gitzone config - manage release registry configuration
// gitzone config - manage CLI smartconfig configuration
import * as plugins from "./mod.plugins.js";
import { ReleaseConfig } from "./classes.releaseconfig.js";
@@ -13,6 +13,10 @@ import {
unsetCliConfigValueInData,
writeSmartconfigFile,
} from "../helpers.smartconfig.js";
import {
CURRENT_GITZONE_CLI_SCHEMA_VERSION,
migrateSmartconfigData,
} from "../helpers.smartconfigmigrations.js";
export { ReleaseConfig, CommitConfig };
@@ -99,6 +103,9 @@ export const run = async (argvArg: any) => {
case "services":
await handleServices(mode);
break;
case "migrate":
await handleMigrate(value, mode);
break;
case "get":
await handleGet(value, mode);
break;
@@ -138,10 +145,11 @@ async function handleInteractiveMenu(): Promise<void> {
default: "show",
choices: [
{ name: "Show current configuration", value: "show" },
{ name: "Add a registry", value: "add" },
{ name: "Remove a registry", value: "remove" },
{ name: "Clear all registries", value: "clear" },
{ name: "Add an npm target registry", value: "add" },
{ name: "Remove an npm target registry", value: "remove" },
{ name: "Clear npm target registries", value: "clear" },
{ name: "Set access level (public/private)", value: "access" },
{ name: "Migrate smartconfig schema", value: "migrate" },
{ name: "Configure commit options", value: "commit" },
{ name: "Configure services", value: "services" },
{ name: "Show help", value: "help" },
@@ -166,6 +174,9 @@ async function handleInteractiveMenu(): Promise<void> {
case "access":
await handleAccessLevel(undefined, defaultCliMode);
break;
case "migrate":
await handleMigrate(undefined, defaultCliMode);
break;
case "commit":
await handleCommit(undefined, undefined, defaultCliMode);
break;
@@ -197,7 +208,7 @@ async function handleShow(mode: ICliMode): Promise<void> {
"╭─────────────────────────────────────────────────────────────╮",
);
console.log(
"│ Release Configuration │",
"│ Release NPM Target Configuration │",
);
console.log(
"╰─────────────────────────────────────────────────────────────╯",
@@ -209,12 +220,12 @@ async function handleShow(mode: ICliMode): Promise<void> {
console.log("");
if (registries.length === 0) {
plugins.logger.log("info", "No release registries configured.");
plugins.logger.log("info", "No npm target registries configured.");
console.log("");
console.log(" Run `gitzone config add <registry-url>` to add one.");
console.log("");
} else {
plugins.logger.log("info", `Configured registries (${registries.length}):`);
plugins.logger.log("info", `Configured npm target registries (${registries.length}):`);
console.log("");
registries.forEach((url, index) => {
console.log(` ${index + 1}. ${url}`);
@@ -224,7 +235,7 @@ async function handleShow(mode: ICliMode): Promise<void> {
}
/**
* Add a registry URL
* Add an npm target registry URL
*/
async function handleAdd(
url: string | undefined,
@@ -240,7 +251,7 @@ async function handleAdd(
const response = await interactInstance.askQuestion({
type: "input",
name: "registryUrl",
message: "Enter registry URL:",
message: "Enter npm target registry URL:",
default: "https://registry.npmjs.org",
validate: (input: string) => {
return !!(input && input.trim() !== "");
@@ -263,7 +274,7 @@ async function handleAdd(
});
return;
}
plugins.logger.log("success", `Added registry: ${url}`);
plugins.logger.log("success", `Added npm target registry: ${url}`);
await formatSmartconfigWithDiff(mode);
} else {
plugins.logger.log("warn", `Registry already exists: ${url}`);
@@ -271,7 +282,7 @@ async function handleAdd(
}
/**
* Remove a registry URL
* Remove an npm target registry URL
*/
async function handleRemove(
url: string | undefined,
@@ -281,7 +292,7 @@ async function handleRemove(
const registries = config.getRegistries();
if (registries.length === 0) {
plugins.logger.log("warn", "No registries configured to remove.");
plugins.logger.log("warn", "No npm target registries configured to remove.");
return;
}
@@ -295,7 +306,7 @@ async function handleRemove(
const response = await interactInstance.askQuestion({
type: "list",
name: "registryUrl",
message: "Select registry to remove:",
message: "Select npm target registry to remove:",
choices: registries,
default: registries[0],
});
@@ -315,7 +326,7 @@ async function handleRemove(
});
return;
}
plugins.logger.log("success", `Removed registry: ${url}`);
plugins.logger.log("success", `Removed npm target registry: ${url}`);
await formatSmartconfigWithDiff(mode);
} else {
plugins.logger.log("warn", `Registry not found: ${url}`);
@@ -323,20 +334,20 @@ async function handleRemove(
}
/**
* Clear all registries
* Clear all npm target registries
*/
async function handleClear(mode: ICliMode): Promise<void> {
const config = await ReleaseConfig.fromCwd();
if (!config.hasRegistries()) {
plugins.logger.log("info", "No registries to clear.");
plugins.logger.log("info", "No npm target registries to clear.");
return;
}
// Confirm before clearing
const confirmed = mode.interactive
? await plugins.smartinteract.SmartInteract.getCliConfirmation(
"Clear all configured registries?",
"Clear all configured npm target registries?",
false,
)
: true;
@@ -348,7 +359,7 @@ async function handleClear(mode: ICliMode): Promise<void> {
printJson({ ok: true, action: "clear", registries: [] });
return;
}
plugins.logger.log("success", "All registries cleared.");
plugins.logger.log("success", "All npm target registries cleared.");
await formatSmartconfigWithDiff(mode);
} else {
plugins.logger.log("info", "Operation cancelled.");
@@ -473,6 +484,7 @@ async function handleCommitInteractive(config: CommitConfig): Promise<void> {
const selected = (response as any).value || [];
config.setAlwaysTest(selected.includes("alwaysTest"));
config.setAlwaysBuild(selected.includes("alwaysBuild"));
syncCommitStepsFromBooleans(config);
await config.save();
plugins.logger.log("success", "Commit configuration updated");
@@ -496,6 +508,7 @@ async function handleCommitSetting(
} else if (setting === "alwaysBuild") {
config.setAlwaysBuild(boolValue);
}
syncCommitStepsFromBooleans(config);
await config.save();
if (mode.json) {
@@ -506,6 +519,16 @@ async function handleCommitSetting(
await formatSmartconfigWithDiff(mode);
}
function syncCommitStepsFromBooleans(config: CommitConfig): void {
config.setSteps([
"analyze",
...(config.getAlwaysTest() ? ["test"] : []),
...(config.getAlwaysBuild() ? ["build"] : []),
"changelog",
"commit",
]);
}
/**
* Show help for commit subcommand
*/
@@ -636,6 +659,38 @@ async function handleUnset(
plugins.logger.log("success", `Unset ${configPath}`);
}
async function handleMigrate(
rawTargetVersion: string | undefined,
mode: ICliMode,
): Promise<void> {
const targetVersion = rawTargetVersion
? Number(rawTargetVersion)
: CURRENT_GITZONE_CLI_SCHEMA_VERSION;
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
throw new Error("Migration target version must be a positive integer");
}
const smartconfigData = await readSmartconfigFile();
const result = migrateSmartconfigData(smartconfigData, targetVersion);
if (result.migrated) {
await writeSmartconfigFile(smartconfigData);
}
if (mode.json) {
printJson({ ok: true, action: "migrate", ...result });
return;
}
if (result.migrated) {
plugins.logger.log(
"success",
`Migrated .smartconfig.json from schema v${result.fromVersion} to v${result.toVersion}`,
);
} else {
plugins.logger.log("info", `.smartconfig.json already at schema v${result.toVersion}`);
}
}
function parseConfigValue(rawValue: string): any {
const trimmedValue = rawValue.trim();
if (trimmedValue === "true") {
@@ -676,21 +731,25 @@ export function showHelp(mode?: ICliMode): void {
{ name: "get <path>", description: "Read a single config value" },
{ name: "set <path> <value>", description: "Write a config value" },
{ name: "unset <path>", description: "Delete a config value" },
{ name: "add [url]", description: "Add a release registry" },
{ name: "remove [url]", description: "Remove a release registry" },
{ name: "clear", description: "Clear all release registries" },
{ name: "add [url]", description: "Add an npm release target registry" },
{ name: "remove [url]", description: "Remove an npm release target registry" },
{ name: "clear", description: "Clear npm release target registries" },
{
name: "access [public|private]",
description: "Set npm publish access level",
description: "Set npm target publish access level",
},
{
name: "commit <setting> <value>",
description: "Set commit defaults",
},
{
name: "migrate [version]",
description: "Run version-targeted .smartconfig.json migrations",
},
],
examples: [
"gitzone config show --json",
"gitzone config get release.accessLevel",
"gitzone config get release.targets.npm.accessLevel",
"gitzone config set cli.interactive false",
"gitzone config set cli.output json",
],
@@ -708,13 +767,14 @@ export function showHelp(mode?: ICliMode): void {
console.log(" get <path> Read a single config value");
console.log(" set <path> <value> Write a config value");
console.log(" unset <path> Delete a config value");
console.log(" add [url] Add a registry URL");
console.log(" remove [url] Remove a registry URL");
console.log(" clear Clear all registries");
console.log(" add [url] Add an npm target registry URL");
console.log(" remove [url] Remove an npm target registry URL");
console.log(" clear Clear npm target registries");
console.log(
" access [public|private] Set npm access level for publishing",
" access [public|private] Set npm target access level for publishing",
);
console.log(" commit [setting] [value] Configure commit options");
console.log(" migrate [version] Run version-targeted smartconfig migrations");
console.log(
" services Configure which services are enabled",
);
@@ -722,7 +782,7 @@ export function showHelp(mode?: ICliMode): void {
console.log("Examples:");
console.log(" gitzone config show");
console.log(" gitzone config show --json");
console.log(" gitzone config get release.accessLevel");
console.log(" gitzone config get release.targets.npm.accessLevel");
console.log(" gitzone config set cli.interactive false");
console.log(" gitzone config set cli.output json");
console.log(" gitzone config unset cli.output");
@@ -732,6 +792,7 @@ export function showHelp(mode?: ICliMode): void {
console.log(" gitzone config clear");
console.log(" gitzone config access public");
console.log(" gitzone config access private");
console.log(" gitzone config migrate 2");
console.log(" gitzone config commit # Interactive");
console.log(" gitzone config commit alwaysTest true");
console.log(" gitzone config services # Interactive");
@@ -2,65 +2,7 @@ import { BaseFormatter } from "../classes.baseformatter.js";
import type { IPlannedChange } from "../interfaces.format.js";
import * as plugins from "../mod.plugins.js";
import { logger, logVerbose } from "../../gitzone.logging.js";
/**
* Migrates .smartconfig.json from old namespace keys to new package-scoped keys
*/
const migrateNamespaceKeys = (smartconfigJson: any): boolean => {
let migrated = false;
const migrations = [
{ oldKey: "gitzone", newKey: "@git.zone/cli" },
{ oldKey: "tsdoc", newKey: "@git.zone/tsdoc" },
{ oldKey: "npmdocker", newKey: "@git.zone/tsdocker" },
{ oldKey: "npmci", newKey: "@ship.zone/szci" },
{ oldKey: "szci", newKey: "@ship.zone/szci" },
];
for (const { oldKey, newKey } of migrations) {
if (smartconfigJson[oldKey]) {
if (!smartconfigJson[newKey]) {
smartconfigJson[newKey] = smartconfigJson[oldKey];
} else {
smartconfigJson[newKey] = {
...smartconfigJson[oldKey],
...smartconfigJson[newKey],
};
}
delete smartconfigJson[oldKey];
migrated = true;
}
}
return migrated;
};
/**
* Migrates npmAccessLevel from @ship.zone/szci to @git.zone/cli.release.accessLevel
*/
const migrateAccessLevel = (smartconfigJson: any): boolean => {
const szciConfig = smartconfigJson["@ship.zone/szci"];
if (!szciConfig?.npmAccessLevel) {
return false;
}
const gitzoneConfig = smartconfigJson["@git.zone/cli"] || {};
if (gitzoneConfig?.release?.accessLevel) {
delete szciConfig.npmAccessLevel;
return true;
}
if (!smartconfigJson["@git.zone/cli"]) {
smartconfigJson["@git.zone/cli"] = {};
}
if (!smartconfigJson["@git.zone/cli"].release) {
smartconfigJson["@git.zone/cli"].release = {};
}
smartconfigJson["@git.zone/cli"].release.accessLevel =
szciConfig.npmAccessLevel;
delete szciConfig.npmAccessLevel;
return true;
};
import { migrateSmartconfigData } from "../../helpers.smartconfigmigrations.js";
const CONFIG_FILE = ".smartconfig.json";
@@ -88,9 +30,7 @@ export class SmartconfigFormatter extends BaseFormatter {
const smartconfigJson = JSON.parse(currentContent);
// Apply key migrations
migrateNamespaceKeys(smartconfigJson);
migrateAccessLevel(smartconfigJson);
migrateSmartconfigData(smartconfigJson);
// Ensure namespaces exist
if (!smartconfigJson["@git.zone/cli"]) {
+393
View File
@@ -0,0 +1,393 @@
import * as plugins from "./mod.plugins.js";
import * as paths from "../paths.js";
import { logger } from "../gitzone.logging.js";
import type { ICliMode } from "../helpers.climode.js";
import { getCliMode, printJson } from "../helpers.climode.js";
import {
inferVersionTypeFromPending,
movePendingToVersion,
readPendingChangelog,
} from "../helpers.changelog.js";
import {
resolveReleaseWorkflow,
type IResolvedReleaseWorkflow,
} from "../helpers.workflow.js";
import * as commitHelpers from "../mod_commit/mod.helpers.js";
type TTargetStatus = "success" | "already-published" | "skipped" | "failed";
interface ITargetResult {
target: string;
status: TTargetStatus;
message?: string;
}
export const run = async (argvArg: any) => {
const mode = await getCliMode(argvArg);
const subcommand = argvArg._?.[1];
if (mode.help || subcommand === "help") {
showHelp(mode);
return;
}
if (mode.json) {
printJson({
ok: false,
error: "JSON output is not supported for mutating release workflows yet. Use `gitzone release --plan` for a human-readable plan.",
});
return;
}
const workflow = await resolveReleaseWorkflow(argvArg);
printReleasePlan(workflow);
if (workflow.confirmation === "plan") {
return;
}
const smartshellInstance = new plugins.smartshell.Smartshell({
executor: "bash",
sourceFilePaths: [],
});
const pending = await readPendingChangelog(
plugins.path.join(paths.cwd, workflow.changelogFile),
workflow.changelogPendingSection,
);
if (pending.isEmpty && !argvArg["allow-empty"] && !argvArg.allowEmpty) {
logger.log("error", "No pending changelog entries. Nothing to release.");
process.exit(1);
}
const versionType = resolveVersionType(argvArg, pending.block);
const projectType = await commitHelpers.detectProjectType();
const currentVersion = await commitHelpers.readCurrentVersion(projectType);
const plannedVersion = commitHelpers.calculateNewVersion(currentVersion, versionType);
if (workflow.confirmation === "prompt") {
if (!mode.interactive) {
throw new Error("Release confirmation requires an interactive terminal. Use `-y` or set release.confirmation to `auto`.");
}
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
`Release v${plannedVersion} (${versionType}) now?`,
true,
);
if (!confirmed) {
logger.log("info", "Release cancelled.");
return;
}
}
let newVersion = plannedVersion;
const gitResults: ITargetResult[] = [];
const npmResults: ITargetResult[] = [];
const dockerResults: ITargetResult[] = [];
if (workflow.requireCleanTree) {
await verifyCleanTree(smartshellInstance, "Working tree is not clean. Commit or stash changes before releasing.");
}
if (workflow.runTests) {
await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand);
}
newVersion = await runVersionStep(projectType, versionType);
await runChangelogStep(workflow, newVersion);
await runReleaseCommitStep(smartshellInstance, newVersion);
await runTagStep(smartshellInstance, newVersion);
if (workflow.runBuild) {
await runCommandStep(smartshellInstance, "Running release build", workflow.buildCommand);
await verifyCleanTree(smartshellInstance, "Build produced uncommitted changes. Aborting release.");
}
if (workflow.targets.includes("git")) {
gitResults.push(...(await runGitTarget(smartshellInstance, workflow)));
}
if (workflow.targets.includes("npm")) {
npmResults.push(...(await runNpmTarget(smartshellInstance, workflow)));
}
if (workflow.targets.includes("docker")) {
dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow, newVersion)));
}
printReleaseSummary(newVersion, gitResults, npmResults, dockerResults);
if ([...gitResults, ...npmResults, ...dockerResults].some((result) => result.status === "failed")) {
process.exit(1);
}
};
function resolveVersionType(argvArg: any, pendingBlock: string): commitHelpers.VersionType {
if (argvArg.major) return "major";
if (argvArg.minor) return "minor";
if (argvArg.patch) return "patch";
return inferVersionTypeFromPending(pendingBlock);
}
async function runCommandStep(
smartshellInstance: plugins.smartshell.Smartshell,
label: string,
command: string,
): Promise<void> {
console.log(`\n${label}`);
const result = await smartshellInstance.exec(command);
if (result.exitCode !== 0) {
logger.log("error", `${label} failed. Aborting release.`);
process.exit(1);
}
logger.log("success", `${label} passed.`);
}
async function verifyCleanTree(
smartshellInstance: plugins.smartshell.Smartshell,
errorMessage: string,
): Promise<void> {
const statusResult = await smartshellInstance.exec("git status --porcelain");
if (statusResult.stdout.trim() !== "") {
logger.log("error", errorMessage);
console.log(statusResult.stdout);
process.exit(1);
}
}
async function runVersionStep(
projectType: commitHelpers.ProjectType,
versionType: commitHelpers.VersionType,
): Promise<string> {
const currentVersion = await commitHelpers.readCurrentVersion(projectType);
const newVersion = commitHelpers.calculateNewVersion(currentVersion, versionType);
logger.log("info", `Bumping version: ${currentVersion} -> ${newVersion}`);
const commitInfo = new plugins.commitinfo.CommitInfo(paths.cwd, versionType);
await commitInfo.writeIntoPotentialDirs();
await commitHelpers.updateProjectVersionFiles(projectType, newVersion);
return newVersion;
}
async function runChangelogStep(
workflow: IResolvedReleaseWorkflow,
newVersion: string,
): Promise<void> {
const dateString = new Date().toISOString().slice(0, 10);
await movePendingToVersion(
plugins.path.join(paths.cwd, workflow.changelogFile),
workflow.changelogPendingSection,
workflow.changelogVersionHeading,
newVersion,
dateString,
);
}
async function runReleaseCommitStep(
smartshellInstance: plugins.smartshell.Smartshell,
newVersion: string,
): Promise<void> {
await smartshellInstance.exec("git add -A");
const result = await smartshellInstance.exec(`git commit -m ${shellQuote(`v${newVersion}`)}`);
if (result.exitCode !== 0) {
logger.log("error", "Release commit failed.");
process.exit(1);
}
}
async function runTagStep(
smartshellInstance: plugins.smartshell.Smartshell,
newVersion: string,
): Promise<void> {
const result = await smartshellInstance.exec(`git tag v${newVersion} -m ${shellQuote(`v${newVersion}`)}`);
if (result.exitCode !== 0) {
logger.log("error", "Release tag failed.");
process.exit(1);
}
}
async function runGitTarget(
smartshellInstance: plugins.smartshell.Smartshell,
workflow: IResolvedReleaseWorkflow,
): Promise<ITargetResult[]> {
const currentBranchResult = await smartshellInstance.exec("git branch --show-current");
const currentBranch = currentBranchResult.stdout.trim() || "master";
const commands: Array<{ target: string; command: string }> = [];
if (workflow.pushBranch) {
commands.push({
target: `${workflow.gitRemote}/${currentBranch}`,
command: `git push ${workflow.gitRemote} ${currentBranch}`,
});
}
if (workflow.pushTags) {
commands.push({
target: `${workflow.gitRemote}/tags`,
command: `git push ${workflow.gitRemote} --tags`,
});
}
const results: ITargetResult[] = [];
for (const { target, command } of commands) {
const result = await smartshellInstance.exec(command);
results.push({
target,
status: result.exitCode === 0 ? "success" : "failed",
message: result.exitCode === 0 ? undefined : "push failed",
});
}
return results;
}
async function runNpmTarget(
smartshellInstance: plugins.smartshell.Smartshell,
workflow: IResolvedReleaseWorkflow,
): Promise<ITargetResult[]> {
if (!workflow.npmEnabled) {
return [{ target: "npm", status: "skipped", message: "disabled" }];
}
if (workflow.npmRegistries.length === 0) {
return [{ target: "npm", status: "failed", message: "no registries configured" }];
}
const results: ITargetResult[] = [];
for (const registry of workflow.npmRegistries) {
const command = `pnpm publish --registry=${registry} --access=${workflow.npmAccessLevel}`;
const result = await smartshellInstance.exec(command);
const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`;
if (result.exitCode === 0) {
results.push({ target: registry, status: "success" });
} else if (isAlreadyPublishedOutput(output) && workflow.npmAlreadyPublished === "success") {
results.push({ target: registry, status: "already-published" });
} else {
results.push({ target: registry, status: "failed", message: firstMeaningfulLine(output) });
}
}
return results;
}
async function runDockerTarget(
smartshellInstance: plugins.smartshell.Smartshell,
workflow: IResolvedReleaseWorkflow,
newVersion: string,
): Promise<ITargetResult[]> {
if (!workflow.dockerEnabled) {
return [{ target: "docker", status: "skipped", message: "disabled" }];
}
if (workflow.dockerImages.length === 0) {
return [{ target: "docker", status: "failed", message: "no images configured" }];
}
const results: ITargetResult[] = [];
for (const imageTemplate of workflow.dockerImages) {
const image = imageTemplate.replaceAll("{{version}}", newVersion);
const buildResult = await smartshellInstance.exec(`docker build -t ${shellQuote(image)} .`);
if (buildResult.exitCode !== 0) {
results.push({ target: image, status: "failed", message: "docker build failed" });
continue;
}
const pushResult = await smartshellInstance.exec(`docker push ${shellQuote(image)}`);
results.push({
target: image,
status: pushResult.exitCode === 0 ? "success" : "failed",
message: pushResult.exitCode === 0 ? undefined : "docker push failed",
});
}
return results;
}
function isAlreadyPublishedOutput(output: string): boolean {
return /previously published versions|cannot publish over|already exists/i.test(output);
}
function firstMeaningfulLine(output: string): string {
return output
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0) || "command failed";
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function printReleasePlan(workflow: IResolvedReleaseWorkflow): void {
console.log("");
console.log("gitzone release - resolved workflow");
console.log(`confirmation: ${workflow.confirmation}`);
console.log(`plan: ${workflow.plan.join(" -> ")}`);
console.log(`targets: ${workflow.targets.length > 0 ? workflow.targets.join(", ") : "none"}`);
console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogPendingSection}`);
if (workflow.targets.includes("npm")) {
console.log(`npm registries: ${workflow.npmRegistries.length > 0 ? workflow.npmRegistries.join(", ") : "none"}`);
}
if (workflow.targets.includes("docker")) {
console.log(`docker images: ${workflow.dockerImages.length > 0 ? workflow.dockerImages.join(", ") : "none"}`);
}
console.log("");
}
function printReleaseSummary(
newVersion: string,
gitResults: ITargetResult[],
npmResults: ITargetResult[],
dockerResults: ITargetResult[],
): void {
console.log("");
console.log(`Release v${newVersion}`);
console.log("");
if (gitResults.length > 0) {
console.log("git:");
for (const result of gitResults) {
console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
}
}
if (npmResults.length > 0) {
console.log("npm:");
for (const result of npmResults) {
console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
}
}
if (dockerResults.length > 0) {
console.log("docker:");
for (const result of dockerResults) {
console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
}
}
}
export function showHelp(mode?: ICliMode): void {
if (mode?.json) {
printJson({
command: "release",
usage: "gitzone release [options]",
description: "Creates a versioned release from pending changelog entries and publishes configured artifacts.",
flags: [
{ flag: "-y, --yes", description: "Run without interactive confirmation" },
{ flag: "-t, --test", description: "Enable release preflight tests" },
{ flag: "-b, --build", description: "Enable release preflight build" },
{ flag: "-p, --push", description: "Enable the git release target" },
{ flag: "--target <names>", description: "Release only selected targets: git,npm,docker" },
{ flag: "--npm", description: "Enable the npm release target" },
{ flag: "--docker", description: "Enable the Docker release target" },
{ flag: "--no-publish", description: "Run release core and git target only" },
{ flag: "--plan", description: "Show resolved workflow without mutating files" },
],
});
return;
}
console.log("");
console.log("Usage: gitzone release [options]");
console.log("");
console.log("Creates a versioned release from changelog Pending entries.");
console.log("");
console.log("Flags:");
console.log(" -y, --yes Run without interactive confirmation");
console.log(" -t, --test Enable release preflight tests");
console.log(" -b, --build Enable release preflight build");
console.log(" -p, --push Enable the git release target");
console.log(" --target <names> Release only selected targets: git,npm,docker");
console.log(" --npm Enable the npm release target");
console.log(" --docker Enable the Docker release target");
console.log(" --no-publish Run release core and git target only");
console.log(" --major|--minor|--patch Override inferred semver level");
console.log(" --plan Show resolved workflow without mutating files");
console.log("");
}
+5
View File
@@ -0,0 +1,5 @@
export * from "../plugins.js";
import * as commitinfo from "@push.rocks/commitinfo";
export { commitinfo };
+15 -2
View File
@@ -17,8 +17,9 @@ const commandSummaries: ICommandHelpSummary[] = [
{
name: "commit",
description:
"Create semantic commits or generate read-only commit recommendations",
"Analyze changes and create semantic source commits",
},
{ name: "release", description: "Create versioned releases from pending changelog entries" },
{ name: "format", description: "Plan or apply project formatting changes" },
{ name: "config", description: "Read and change .smartconfig.json settings" },
{ name: "services", description: "Manage or configure development services" },
@@ -68,7 +69,8 @@ export let run = async (argvArg: any = {}) => {
message: "What would you like to do?",
default: "commit",
choices: [
{ name: "Commit changes (semantic versioning)", value: "commit" },
{ name: "Commit changes", value: "commit" },
{ name: "Release pending changes", value: "release" },
{ name: "Format project files", value: "format" },
{ name: "Configure release settings", value: "config" },
{ name: "Create from template", value: "template" },
@@ -86,6 +88,11 @@ export let run = async (argvArg: any = {}) => {
await modCommit.run({ _: ["commit"] });
break;
}
case "release": {
const modRelease = await import("../mod_release/index.js");
await modRelease.run({ _: ["release"] });
break;
}
case "format": {
const modFormat = await import("../mod_format/index.js");
await modFormat.run({ interactive: true });
@@ -186,6 +193,7 @@ export async function showHelp(
console.log(" gitzone help commit");
console.log(" gitzone config show --json");
console.log(" gitzone commit recommend --json");
console.log(" gitzone release --plan");
console.log(" gitzone format plan --json");
console.log(" gitzone services set mongodb,minio");
console.log("");
@@ -203,6 +211,11 @@ async function showCommandHelp(
modCommit.showHelp(mode);
return true;
}
case "release": {
const modRelease = await import("../mod_release/index.js");
modRelease.showHelp(mode);
return true;
}
case "config": {
const modConfig = await import("../mod_config/index.js");
modConfig.showHelp(mode);