From 2594b2ff0cd2ac68ea17b6fbb978062688ed1d3c Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Fri, 20 Feb 2026 21:59:03 -0700 Subject: [PATCH 01/11] fix(Formatting): Fixed the formatting of the GPG Menu in `init` --- src/cli/commands/commit/index.ts | 68 ++++- src/cli/commands/init/gpg.ts | 466 +++++++++++++++++++++++++++++++ src/cli/commands/init/index.ts | 65 +++++ src/cli/commands/init/prompts.ts | 242 +++++++++++++++- src/cli/utils/terminal.ts | 65 +++++ src/lib/presets/index.ts | 6 +- 6 files changed, 890 insertions(+), 22 deletions(-) create mode 100644 src/cli/commands/init/gpg.ts create mode 100644 src/cli/utils/terminal.ts diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index 9c6a12e..e225823 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -377,11 +377,35 @@ export async function commitAction(options: { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`\n✗ Error: Git commit failed`); - console.error(`\n ${errorMessage}`); - console.error( - "\n Fix: Check 'git status' and verify staged files, then try again\n", - ); + + // Detect GPG-specific failures + const isGpgError = + errorMessage.includes("gpg failed to sign") || + errorMessage.includes("secret key not available") || + errorMessage.includes("signing failed") || + errorMessage.includes("gpg: skipped") || + errorMessage.includes("gpg: signing failed"); + + if (isGpgError) { + // GPG-specific error handling + console.error(`\n✗ Error: Commit signing failed`); + console.error(`\n GPG could not sign this commit.`); + console.error(`\n Possible causes:`); + console.error(` • GPG key expired or revoked`); + console.error(` • GPG agent not running`); + console.error(` • Passphrase entry failed`); + console.error( + `\n To disable signing, set 'sign_commits: false' in .labcommitr.config.yaml`, + ); + console.error(` Or run: git config --global commit.gpgsign false\n`); + } else { + // Generic error handling + console.error(`\n✗ Error: Git commit failed`); + console.error(`\n ${errorMessage}`); + console.error( + "\n Fix: Check 'git status' and verify staged files, then try again\n", + ); + } process.exit(1); } } else { @@ -541,11 +565,35 @@ export async function commitAction(options: { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`\n✗ Error: Git commit failed`); - console.error(`\n ${errorMessage}`); - console.error( - "\n Fix: Check 'git status' and verify staged files, then try again\n", - ); + + // Detect GPG-specific failures + const isGpgError = + errorMessage.includes("gpg failed to sign") || + errorMessage.includes("secret key not available") || + errorMessage.includes("signing failed") || + errorMessage.includes("gpg: skipped") || + errorMessage.includes("gpg: signing failed"); + + if (isGpgError) { + // GPG-specific error handling + console.error(`\n✗ Error: Commit signing failed`); + console.error(`\n GPG could not sign this commit.`); + console.error(`\n Possible causes:`); + console.error(` • GPG key expired or revoked`); + console.error(` • GPG agent not running`); + console.error(` • Passphrase entry failed`); + console.error( + `\n To disable signing, set 'sign_commits: false' in .labcommitr.config.yaml`, + ); + console.error(` Or run: git config --global commit.gpgsign false\n`); + } else { + // Generic error handling + console.error(`\n✗ Error: Git commit failed`); + console.error(`\n ${errorMessage}`); + console.error( + "\n Fix: Check 'git status' and verify staged files, then try again\n", + ); + } process.exit(1); } } diff --git a/src/cli/commands/init/gpg.ts b/src/cli/commands/init/gpg.ts new file mode 100644 index 0000000..e3fefef --- /dev/null +++ b/src/cli/commands/init/gpg.ts @@ -0,0 +1,466 @@ +/** + * GPG Capabilities Detection and Setup + * + * Provides centralized GPG detection, package manager detection, + * and configuration utilities for commit signing setup. + * + * Detection Flow: + * 1. Check if GPG is installed (gpg --version) + * 2. Check for existing signing keys (gpg --list-secret-keys) + * 3. Check if Git is configured for signing (git config user.signingkey) + * 4. Determine overall capability state + */ + +import { spawnSync } from "child_process"; +import { platform } from "os"; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * GPG capability states representing the user's signing readiness + */ +export type GpgState = + | "fully_configured" // GPG + keys + Git configured + | "partial_config" // GPG + keys, Git not configured + | "no_keys" // GPG installed, no keys + | "not_installed"; // GPG not found + +/** + * Detailed GPG capabilities information + */ +export interface GpgCapabilities { + state: GpgState; + gpgInstalled: boolean; + gpgVersion: string | null; + keysExist: boolean; + keyId: string | null; + keyEmail: string | null; + gitConfigured: boolean; + gitSigningKey: string | null; +} + +/** + * Supported package managers for GPG installation + */ +export type PackageManager = + | "brew" // macOS + | "apt" // Debian/Ubuntu + | "dnf" // Fedora/RHEL + | "pacman" // Arch + | "winget" // Windows 10+ + | "choco" // Windows (Chocolatey) + | null; // None detected + +/** + * Platform-specific installation information + */ +export interface PlatformInfo { + os: "darwin" | "linux" | "win32" | "unknown"; + packageManager: PackageManager; + installCommand: string | null; + manualInstallUrl: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Install commands for each package manager + */ +const INSTALL_COMMANDS: Record, string> = { + brew: "brew install gnupg", + apt: "sudo apt-get install gnupg", + dnf: "sudo dnf install gnupg2", + pacman: "sudo pacman -S gnupg", + winget: "winget install GnuPG.GnuPG", + choco: "choco install gnupg", +}; + +/** + * Manual installation URL for GPG + */ +const MANUAL_INSTALL_URL = "https://gnupg.org/download/"; + +/** + * Timeout for detection commands (5 seconds) + */ +const DETECTION_TIMEOUT = 5000; + +/** + * Timeout for key generation (2 minutes) + */ +const KEY_GENERATION_TIMEOUT = 120000; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Execute a command and return the result + * Uses spawnSync with timeout for safety + */ +function execCommand( + command: string, + args: string[], + timeout: number = DETECTION_TIMEOUT, +): { success: boolean; output: string; stderr: string } { + try { + const result = spawnSync(command, args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout, + }); + + if (result.error) { + return { success: false, output: "", stderr: result.error.message }; + } + + return { + success: result.status === 0, + output: result.stdout?.toString().trim() || "", + stderr: result.stderr?.toString().trim() || "", + }; + } catch { + return { success: false, output: "", stderr: "Command execution failed" }; + } +} + +/** + * Check if a command exists on the system + * Uses platform-specific detection method + */ +function commandExists(command: string): boolean { + const os = platform(); + + if (os === "win32") { + // Windows: use 'where' command + const result = execCommand("where", [command]); + return result.success; + } else { + // Unix-like: use 'command -v' via shell + const result = spawnSync("sh", ["-c", `command -v ${command}`], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: DETECTION_TIMEOUT, + }); + return result.status === 0; + } +} + +// ============================================================================ +// GPG Detection Functions +// ============================================================================ + +/** + * Detect GPG installation and version + */ +function detectGpgInstallation(): { installed: boolean; version: string | null } { + const result = execCommand("gpg", ["--version"]); + + if (!result.success) { + return { installed: false, version: null }; + } + + // Parse version from first line: "gpg (GnuPG) 2.2.41" or "gpg (GnuPG/MacGPG2) 2.2.41" + const firstLine = result.output.split("\n")[0] || ""; + const versionMatch = firstLine.match(/(\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : null; + + return { installed: true, version }; +} + +/** + * Detect existing GPG signing keys + */ +function detectGpgKeys(): { + keysExist: boolean; + keyId: string | null; + keyEmail: string | null; +} { + const result = execCommand("gpg", [ + "--list-secret-keys", + "--keyid-format", + "LONG", + ]); + + if (!result.success || !result.output) { + return { keysExist: false, keyId: null, keyEmail: null }; + } + + // Parse key ID from "sec" line: "sec rsa4096/AF08DCD261E298CB 2024-11-16" + const secMatch = result.output.match(/sec\s+\w+\/([A-F0-9]+)/i); + const keyId = secMatch ? secMatch[1] : null; + + // Parse email from "uid" line: "uid [ultimate] Name " + const uidMatch = result.output.match(/uid\s+.*<([^>]+)>/); + const keyEmail = uidMatch ? uidMatch[1] : null; + + return { + keysExist: !!keyId, + keyId, + keyEmail, + }; +} + +/** + * Check if Git is configured for commit signing + */ +function detectGitSigningConfig(): { + configured: boolean; + signingKey: string | null; +} { + const result = execCommand("git", ["config", "--get", "user.signingkey"]); + + if (!result.success || !result.output) { + return { configured: false, signingKey: null }; + } + + return { + configured: true, + signingKey: result.output, + }; +} + +/** + * Detect complete GPG capabilities + * Main entry point for GPG detection + */ +export function detectGpgCapabilities(): GpgCapabilities { + // Step 1: Check GPG installation + const gpgInstall = detectGpgInstallation(); + + if (!gpgInstall.installed) { + return { + state: "not_installed", + gpgInstalled: false, + gpgVersion: null, + keysExist: false, + keyId: null, + keyEmail: null, + gitConfigured: false, + gitSigningKey: null, + }; + } + + // Step 2: Check for signing keys + const keys = detectGpgKeys(); + + if (!keys.keysExist) { + return { + state: "no_keys", + gpgInstalled: true, + gpgVersion: gpgInstall.version, + keysExist: false, + keyId: null, + keyEmail: null, + gitConfigured: false, + gitSigningKey: null, + }; + } + + // Step 3: Check Git configuration + const gitConfig = detectGitSigningConfig(); + + if (!gitConfig.configured) { + return { + state: "partial_config", + gpgInstalled: true, + gpgVersion: gpgInstall.version, + keysExist: true, + keyId: keys.keyId, + keyEmail: keys.keyEmail, + gitConfigured: false, + gitSigningKey: null, + }; + } + + // Fully configured + return { + state: "fully_configured", + gpgInstalled: true, + gpgVersion: gpgInstall.version, + keysExist: true, + keyId: keys.keyId, + keyEmail: keys.keyEmail, + gitConfigured: true, + gitSigningKey: gitConfig.signingKey, + }; +} + +// ============================================================================ +// Package Manager Detection +// ============================================================================ + +/** + * Detect available package manager for GPG installation + */ +export function detectPackageManager(): PlatformInfo { + const os = platform(); + const manualInstallUrl = MANUAL_INSTALL_URL; + + // Map Node.js platform to our OS type + let detectedOs: PlatformInfo["os"]; + switch (os) { + case "darwin": + detectedOs = "darwin"; + break; + case "linux": + detectedOs = "linux"; + break; + case "win32": + detectedOs = "win32"; + break; + default: + detectedOs = "unknown"; + } + + // Detect package manager based on platform + let packageManager: PackageManager = null; + + switch (detectedOs) { + case "darwin": + // macOS: check for Homebrew + if (commandExists("brew")) { + packageManager = "brew"; + } + break; + + case "linux": + // Linux: check in order of popularity + if (commandExists("apt-get")) { + packageManager = "apt"; + } else if (commandExists("dnf")) { + packageManager = "dnf"; + } else if (commandExists("pacman")) { + packageManager = "pacman"; + } + break; + + case "win32": + // Windows: check winget first (built-in on Windows 10+), then Chocolatey + if (commandExists("winget")) { + packageManager = "winget"; + } else if (commandExists("choco")) { + packageManager = "choco"; + } + break; + } + + // Get install command if package manager found + const installCommand = packageManager + ? INSTALL_COMMANDS[packageManager] + : null; + + return { + os: detectedOs, + packageManager, + installCommand, + manualInstallUrl, + }; +} + +// ============================================================================ +// GPG Configuration Functions +// ============================================================================ + +/** + * Configure Git to use a specific GPG key for signing + * Sets both user.signingkey and commit.gpgsign globally + */ +export function configureGitSigning(keyId: string): boolean { + // Set the signing key + const keyResult = execCommand("git", [ + "config", + "--global", + "user.signingkey", + keyId, + ]); + + if (!keyResult.success) { + return false; + } + + // Enable commit signing by default + const signResult = execCommand("git", [ + "config", + "--global", + "commit.gpgsign", + "true", + ]); + + return signResult.success; +} + +/** + * Generate a new GPG key using the user's Git identity + * Uses gpg --quick-generate-key for non-interactive generation + */ +export async function generateGpgKey(): Promise { + // Get user info from Git config + const nameResult = execCommand("git", ["config", "--get", "user.name"]); + const emailResult = execCommand("git", ["config", "--get", "user.email"]); + + const name = nameResult.output.trim(); + const email = emailResult.output.trim(); + + if (!name || !email) { + console.error("\n Git user.name and user.email must be configured first."); + console.error(' Run: git config --global user.name "Your Name"'); + console.error(' git config --global user.email "you@example.com"'); + return false; + } + + console.log(`\n Generating GPG key for: ${name} <${email}>`); + console.log(" This may take a moment...\n"); + + // Use gpg --quick-generate-key for non-interactive generation + // RSA 4096-bit key, sign-only capability, expires in 2 years + const result = spawnSync( + "gpg", + [ + "--batch", + "--quick-generate-key", + `${name} <${email}>`, + "rsa4096", + "sign", + "2y", + ], + { + encoding: "utf-8", + stdio: ["inherit", "pipe", "pipe"], // inherit stdin for passphrase + timeout: KEY_GENERATION_TIMEOUT, + }, + ); + + if (result.status === 0) { + console.log(" GPG key generated successfully!\n"); + return true; + } else { + const errorMsg = result.stderr?.toString().trim() || "Unknown error"; + console.error(`\n Failed to generate GPG key: ${errorMsg}\n`); + return false; + } +} + +/** + * Test if GPG signing actually works with the configured key + * Useful for verifying the setup is complete + */ +export function testGpgSigning(keyId: string): boolean { + // Create a test signature + const result = spawnSync( + "gpg", + ["--batch", "--yes", "--clearsign", "--default-key", keyId], + { + input: "test", + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: DETECTION_TIMEOUT, + }, + ); + + return result.status === 0; +} diff --git a/src/cli/commands/init/index.ts b/src/cli/commands/init/index.ts index e07f433..b3c1bd7 100644 --- a/src/cli/commands/init/index.ts +++ b/src/cli/commands/init/index.ts @@ -22,8 +22,19 @@ import { promptEmoji, promptAutoStage, promptBodyRequired, + promptSignCommits, + promptGpgSetup, + promptKeyGeneration, + displayGpgStatus, + displayInstallInstructions, displayProcessingSteps, } from "./prompts.js"; +import { + detectGpgCapabilities, + detectPackageManager, + configureGitSigning, + generateGpgKey, +} from "./gpg.js"; import { buildConfig, getPreset } from "../../../lib/presets/index.js"; import { generateConfigFile } from "./config-generator.js"; import { Logger } from "../../../lib/logger.js"; @@ -107,6 +118,59 @@ async function initAction(options: { const autoStage = await promptAutoStage(); const bodyRequired = await promptBodyRequired(); + // === GPG Detection Phase === + const gpgCapabilities = detectGpgCapabilities(); + let signCommits = false; + + // Display current GPG status + displayGpgStatus(gpgCapabilities); + + switch (gpgCapabilities.state) { + case "fully_configured": { + // User has working GPG - ask if they want to enable + signCommits = await promptSignCommits(gpgCapabilities.state); + break; + } + + case "partial_config": { + // GPG and keys exist, just need Git config + const configure = await promptSignCommits(gpgCapabilities.state); + if (configure && gpgCapabilities.keyId) { + configureGitSigning(gpgCapabilities.keyId); + signCommits = true; + } + break; + } + + case "no_keys": { + // GPG installed but no keys + const action = await promptKeyGeneration(); + if (action === "generate") { + const success = await generateGpgKey(); + if (success) { + // Re-detect after key generation + const updated = detectGpgCapabilities(); + if (updated.keyId) { + configureGitSigning(updated.keyId); + signCommits = true; + } + } + } + break; + } + + case "not_installed": { + // GPG not found + const platformInfo = detectPackageManager(); + const action = await promptGpgSetup(platformInfo); + if (action === "install") { + displayInstallInstructions(platformInfo); + // signCommits stays false - user will re-run init after installing + } + break; + } + } + // Small pause before processing await new Promise((resolve) => setTimeout(resolve, 800)); @@ -119,6 +183,7 @@ async function initAction(options: { scope: "optional", autoStage, bodyRequired, + signCommits, }); // Show title "Labcommitr initializing..." diff --git a/src/cli/commands/init/prompts.ts b/src/cli/commands/init/prompts.ts index 7bffcca..a9248e4 100644 --- a/src/cli/commands/init/prompts.ts +++ b/src/cli/commands/init/prompts.ts @@ -8,7 +8,7 @@ * Label pattern: [colored label] [2 spaces] [content] */ -import { select, multiselect, isCancel } from "@clack/prompts"; +import { select, multiselect, isCancel, log } from "@clack/prompts"; import { labelColors, textColors, @@ -17,6 +17,8 @@ import { attention, highlight, } from "./colors.js"; +import type { GpgState, GpgCapabilities, PlatformInfo } from "./gpg.js"; +import { getAvailableWidth, truncateForPrompt } from "../../utils/terminal.js"; /** * Create compact color-coded label @@ -71,7 +73,6 @@ const PRESET_OPTIONS: Array<{ name: string; description: string; example: string; - hint?: string; }> = [ { value: "conventional", @@ -82,9 +83,9 @@ const PRESET_OPTIONS: Array<{ { value: "angular", name: "Angular Convention", - description: "Strict format used by Angular and enterprise teams.", + description: + "Strict format used by Angular and enterprise teams. Includes perf, build, ci types.", example: "perf(compiler): optimize template parsing", - hint: "Includes perf, build, ci types", }, { value: "minimal", @@ -98,14 +99,32 @@ const PRESET_OPTIONS: Array<{ * Prompt for commit style preset selection */ export async function promptPreset(): Promise { + // @clack renders: "│ ●