feat(cli): add machine-readable CLI help, recommendation, and configuration flows

This commit is contained in:
2026-04-16 18:54:07 +00:00
parent f43f88a3cb
commit fd7a73398c
14 changed files with 2482 additions and 786 deletions
+325 -98
View File
@@ -1,13 +1,41 @@
// this file contains code to create commits in a consistent way
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 * 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";
export const run = async (argvArg: any) => {
const mode = await getCliMode(argvArg);
const subcommand = argvArg._?.[1];
if (mode.help || subcommand === "help") {
showHelp(mode);
return;
}
if (subcommand === "recommend") {
await handleRecommend(mode);
return;
}
if (mode.json) {
printJson({
ok: false,
error:
"JSON output is only supported for the read-only recommendation flow. Use `gitzone commit recommend --json`.",
});
return;
}
// Read commit config from .smartconfig.json
const smartconfigInstance = new plugins.smartconfig.Smartconfig();
const gitzoneConfig = smartconfigInstance.dataFor<{
@@ -15,7 +43,7 @@ export const run = async (argvArg: any) => {
alwaysTest?: boolean;
alwaysBuild?: boolean;
};
}>('@git.zone/cli', {});
}>("@git.zone/cli", {});
const commitConfig = gitzoneConfig.commit || {};
// Check flags and merge with config options
@@ -27,10 +55,12 @@ export const run = async (argvArg: any) => {
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('');
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);
}
}
@@ -47,26 +77,26 @@ export const run = async (argvArg: any) => {
});
if (argvArg.format) {
const formatMod = await import('../mod_format/index.js');
const formatMod = await import("../mod_format/index.js");
await formatMod.run();
}
// Run tests early to fail fast before analysis
if (wantsTest) {
ui.printHeader('🧪 Running tests...');
ui.printHeader("🧪 Running tests...");
const smartshellForTest = new plugins.smartshell.Smartshell({
executor: 'bash',
executor: "bash",
sourceFilePaths: [],
});
const testResult = await smartshellForTest.exec('pnpm test');
const testResult = await smartshellForTest.exec("pnpm test");
if (testResult.exitCode !== 0) {
logger.log('error', 'Tests failed. Aborting commit.');
logger.log("error", "Tests failed. Aborting commit.");
process.exit(1);
}
logger.log('success', 'All tests passed.');
logger.log("success", "All tests passed.");
}
ui.printHeader('🔍 Analyzing repository changes...');
ui.printHeader("🔍 Analyzing repository changes...");
const aidoc = new plugins.tsdoc.AiDoc();
await aidoc.start();
@@ -79,58 +109,63 @@ export const run = async (argvArg: any) => {
recommendedNextVersion: nextCommitObject.recommendedNextVersion,
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage,
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 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)');
logger.log("info", "✓ Auto-accepting AI recommendations (--yes flag)");
answerBucket = new plugins.smartinteract.AnswerBucket();
answerBucket.addAnswer({
name: 'commitType',
name: "commitType",
value: nextCommitObject.recommendedNextVersionLevel,
});
answerBucket.addAnswer({
name: 'commitScope',
name: "commitScope",
value: nextCommitObject.recommendedNextVersionScope,
});
answerBucket.addAnswer({
name: 'commitDescription',
name: "commitDescription",
value: nextCommitObject.recommendedNextVersionMessage,
});
answerBucket.addAnswer({
name: 'pushToOrigin',
name: "pushToOrigin",
value: !!(argvArg.p || argvArg.push), // Only push if -p flag also provided
});
answerBucket.addAnswer({
name: 'createRelease',
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');
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',
type: "list",
name: `commitType`,
message: `Choose TYPE of the commit:`,
choices: [`fix`, `feat`, `BREAKING CHANGE`],
default: nextCommitObject.recommendedNextVersionLevel,
},
{
type: 'input',
type: "input",
name: `commitScope`,
message: `What is the SCOPE of the commit:`,
default: nextCommitObject.recommendedNextVersionScope,
@@ -142,13 +177,13 @@ export const run = async (argvArg: any) => {
default: nextCommitObject.recommendedNextVersionMessage,
},
{
type: 'confirm',
type: "confirm",
name: `pushToOrigin`,
message: `Do you want to push this version now?`,
default: true,
},
{
type: 'confirm',
type: "confirm",
name: `createRelease`,
message: `Do you want to publish to npm registries?`,
default: wantsRelease,
@@ -157,40 +192,50 @@ export const run = async (argvArg: any) => {
answerBucket = await commitInteract.runQueue();
}
const commitString = createCommitStringFromAnswerBucket(answerBucket);
const commitVersionType = (() => {
switch (answerBucket.getAnswerFor('commitType')) {
case 'fix':
return 'patch';
case 'feat':
return 'minor';
case 'BREAKING CHANGE':
return 'major';
}
})();
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.printHeader("✨ Creating Semantic Commit");
ui.printCommitMessage(commitString);
const smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash',
executor: "bash",
sourceFilePaths: [],
});
// Load release config if user wants to release (interactively selected)
if (answerBucket.getAnswerFor('createRelease') && !releaseConfig) {
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('');
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);
}
}
// 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();
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++;
@@ -199,96 +244,156 @@ export const run = async (argvArg: any) => {
// Step 1: Baking commitinfo
currentStep++;
ui.printStep(currentStep, totalSteps, '🔧 Baking commit info into code', 'in-progress');
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');
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;
ui.printStep(
currentStep,
totalSteps,
"📄 Generating changelog.md",
"in-progress",
);
let changelog = nextCommitObject.changelog || "# Changelog\n";
changelog = changelog.replaceAll(
'{{nextVersion}}',
"{{nextVersion}}",
(await commitInfo.getNextPlannedVersion()).versionString,
);
changelog = changelog.replaceAll(
'{{nextVersionScope}}',
`${await answerBucket.getAnswerFor('commitType')}(${await answerBucket.getAnswerFor('commitScope')})`,
"{{nextVersionScope}}",
`${await answerBucket.getAnswerFor("commitType")}(${await answerBucket.getAnswerFor("commitScope")})`,
);
changelog = changelog.replaceAll(
'{{nextVersionMessage}}',
"{{nextVersionMessage}}",
nextCommitObject.recommendedNextVersionMessage,
);
if (nextCommitObject.recommendedNextVersionDetails?.length > 0) {
changelog = changelog.replaceAll(
'{{nextVersionDetails}}',
'- ' + nextCommitObject.recommendedNextVersionDetails.join('\n- '),
"{{nextVersionDetails}}",
"- " + nextCommitObject.recommendedNextVersionDetails.join("\n- "),
);
} else {
changelog = changelog.replaceAll('\n{{nextVersionDetails}}', '');
changelog = changelog.replaceAll("\n{{nextVersionDetails}}", "");
}
await plugins.smartfs
.file(plugins.path.join(paths.cwd, `changelog.md`))
.encoding('utf8')
.encoding("utf8")
.write(changelog);
ui.printStep(currentStep, totalSteps, '📄 Generating changelog.md', 'done');
ui.printStep(currentStep, totalSteps, "📄 Generating changelog.md", "done");
// Step 3: Staging files
currentStep++;
ui.printStep(currentStep, totalSteps, '📦 Staging files', 'in-progress');
ui.printStep(currentStep, totalSteps, "📦 Staging files", "in-progress");
await smartshellInstance.exec(`git add -A`);
ui.printStep(currentStep, totalSteps, '📦 Staging files', 'done');
ui.printStep(currentStep, totalSteps, "📦 Staging files", "done");
// Step 4: Creating commit
currentStep++;
ui.printStep(currentStep, totalSteps, '💾 Creating git commit', 'in-progress');
ui.printStep(
currentStep,
totalSteps,
"💾 Creating git commit",
"in-progress",
);
await smartshellInstance.exec(`git commit -m "${commitString}"`);
ui.printStep(currentStep, totalSteps, '💾 Creating git commit', 'done');
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);
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');
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.');
ui.printStep(currentStep, totalSteps, "🔨 Running build", "error");
logger.log("error", "Build failed. Aborting release.");
process.exit(1);
}
ui.printStep(currentStep, totalSteps, '🔨 Running build', 'done');
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:');
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');
logger.log(
"error",
"Aborting release. Please ensure build artifacts are in .gitignore",
);
process.exit(1);
}
ui.printStep(currentStep, totalSteps, '🔍 Verifying clean working tree', 'done');
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');
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)
@@ -296,51 +401,173 @@ export const run = async (argvArg: any) => {
if (willRelease && releaseConfig) {
currentStep++;
const registries = releaseConfig.getRegistries();
ui.printStep(currentStep, totalSteps, `📦 Publishing to ${registries.length} registr${registries.length === 1 ? 'y' : 'ies'}`, 'in-progress');
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}`);
await smartshellInstance.exec(
`npm publish --registry=${registry} --access=${accessLevel}`,
);
releasedRegistries.push(registry);
} catch (error) {
logger.log('error', `Failed to publish to ${registry}: ${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');
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');
ui.printStep(
currentStep,
totalSteps,
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
"error",
);
}
}
console.log(''); // Add spacing before summary
console.log(""); // Add spacing before summary
// Get commit SHA for summary
const commitShaResult = await smartshellInstance.exec('git rev-parse --short HEAD');
const commitShaResult = await smartshellInstance.exec(
"git rev-parse --short HEAD",
);
const commitSha = commitShaResult.stdout.trim();
// Print final summary
ui.printSummary({
projectType,
branch: currentBranch,
commitType: answerBucket.getAnswerFor('commitType'),
commitScope: answerBucket.getAnswerFor('commitScope'),
commitMessage: answerBucket.getAnswerFor('commitDescription'),
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,
releasedRegistries:
releasedRegistries.length > 0 ? releasedRegistries : undefined,
});
};
async function handleRecommend(mode: ICliMode): Promise<void> {
const recommendationBuilder = async () => {
const aidoc = new plugins.tsdoc.AiDoc();
await aidoc.start();
try {
return await aidoc.buildNextCommitObject(paths.cwd);
} finally {
await aidoc.stop();
}
};
const recommendation = mode.json
? await runWithSuppressedOutput(recommendationBuilder)
: await recommendationBuilder();
if (mode.json) {
printJson(recommendation);
return;
}
ui.printRecommendation({
recommendedNextVersion: recommendation.recommendedNextVersion,
recommendedNextVersionLevel: recommendation.recommendedNextVersionLevel,
recommendedNextVersionScope: recommendation.recommendedNextVersionScope,
recommendedNextVersionMessage: recommendation.recommendedNextVersionMessage,
});
console.log(
`Suggested commit: ${recommendation.recommendedNextVersionLevel}(${recommendation.recommendedNextVersionScope}): ${recommendation.recommendedNextVersionMessage}`,
);
}
const createCommitStringFromAnswerBucket = (
answerBucket: plugins.smartinteract.AnswerBucket,
) => {
const commitType = answerBucket.getAnswerFor('commitType');
const commitScope = answerBucket.getAnswerFor('commitScope');
const commitDescription = answerBucket.getAnswerFor('commitDescription');
const commitType = answerBucket.getAnswerFor("commitType");
const commitScope = answerBucket.getAnswerFor("commitScope");
const commitDescription = answerBucket.getAnswerFor("commitDescription");
return `${commitType}(${commitScope}): ${commitDescription}`;
};
export function showHelp(mode?: ICliMode): void {
if (mode?.json) {
printJson({
command: "commit",
usage: "gitzone commit [recommend] [options]",
description:
"Creates semantic commits or emits a read-only recommendation.",
commands: [
{
name: "recommend",
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",
},
],
examples: [
"gitzone commit recommend --json",
"gitzone commit -y",
"gitzone commit -ypbr",
],
});
return;
}
console.log("");
console.log("Usage: gitzone commit [recommend] [options]");
console.log("");
console.log("Commands:");
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(" --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("");
}