UNPKG

gmana

Version:

A sleek, interactive, and secure CLI tool for generating and managing passwords with modern UX, strong encryption, and smart features.

680 lines (679 loc) • 23.8 kB
#!/usr/bin/env node import { Command } from "commander"; import consola from "consola"; import { confirm, intro, multiselect, outro, select, spinner, text } from "@clack/prompts"; import { bgBlue, bold, cyan, dim, green, red, white, yellow } from "colorette"; import fs from "fs-extra"; import os from "node:os"; import path from "node:path"; import { z } from "zod"; import clipboardy from "clipboardy"; import crypto$1 from "node:crypto"; //#region package.json var version = "1.0.7"; //#endregion //#region src/lib/config.ts const ConfigSchema = z.object({ defaultLength: z.number().int().min(4).max(128).default(12), defaultIncludeUppercase: z.boolean().default(true), defaultIncludeLowercase: z.boolean().default(true), defaultIncludeNumbers: z.boolean().default(true), defaultIncludeSymbols: z.boolean().default(true), autoCopy: z.boolean().default(true), saveHistory: z.boolean().default(false), historyLimit: z.number().int().min(0).max(1e3).default(100) }); const CONFIG_DIR$1 = path.join(os.homedir(), ".gmana"); const CONFIG_FILE = path.join(CONFIG_DIR$1, "config.json"); async function loadConfig() { try { if (await fs.pathExists(CONFIG_FILE)) { const rawConfig = await fs.readJson(CONFIG_FILE); return ConfigSchema.parse(rawConfig); } } catch {} return ConfigSchema.parse({}); } async function saveConfig(config) { await fs.ensureDir(CONFIG_DIR$1); const newConfig = { ...await loadConfig(), ...config }; const validatedConfig = ConfigSchema.parse(newConfig); await fs.writeJson(CONFIG_FILE, validatedConfig, { spaces: 2 }); } //#endregion //#region src/commands/config.ts const configCommand = new Command().name("config").alias("c").description("āš™ļø Manage configuration settings").option("-s, --show", "show current configuration").option("-r, --reset", "reset to default configuration").option("--set <key=value>", "set a configuration value").action(async (options) => { try { if (options.show) await showConfig(); else if (options.reset) await resetConfig(); else if (options.set) await setConfigValue(options.set); else await interactiveConfig(); } catch (error) { if (error && typeof error === "object" && "message" in error) consola.error("Configuration failed:", error.message); else consola.error("An unexpected error occurred"); process.exit(1); } }); async function showConfig() { const config = await loadConfig(); console.log(cyan("\nšŸ“‹ Current Configuration:")); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); [ ["Default Length", config.defaultLength], ["Include Uppercase", config.defaultIncludeUppercase ? "āœ…" : "āŒ"], ["Include Lowercase", config.defaultIncludeLowercase ? "āœ…" : "āŒ"], ["Include Numbers", config.defaultIncludeNumbers ? "āœ…" : "āŒ"], ["Include Symbols", config.defaultIncludeSymbols ? "āœ…" : "āŒ"], ["Auto Copy", config.autoCopy ? "āœ…" : "āŒ"], ["Save History", config.saveHistory ? "āœ…" : "āŒ"], ["History Limit", config.historyLimit] ].forEach(([key, value]) => { console.log(`${bold(key.toString().padEnd(20))}: ${value}`); }); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); } async function resetConfig() { if (await confirm({ message: "Are you sure you want to reset all settings to defaults?" })) { await saveConfig({ defaultLength: 12, defaultIncludeUppercase: true, defaultIncludeLowercase: true, defaultIncludeNumbers: true, defaultIncludeSymbols: true, autoCopy: true, saveHistory: false, historyLimit: 100 }); consola.success("šŸ”„ Configuration reset to defaults"); } else consola.info("Operation cancelled"); } async function setConfigValue(keyValue) { const [rawKey, rawValue] = keyValue.split("="); const key = rawKey?.trim().toLowerCase(); const value = rawValue?.trim(); if (!key || value === void 0) { consola.error("āŒ Invalid format. Use: --set key=value"); return; } const updates = {}; const parseAndValidateNumber = (val, min, max, errorMsg) => { const num = parseInt(val, 10); if (isNaN(num) || num < min || num > max) { consola.error(`āŒ ${errorMsg}`); return null; } return num; }; switch (key) { case "defaultlength": case "length": { const length = parseAndValidateNumber(value, 4, 128, "Length must be between 4 and 128"); if (length === null) return; updates.defaultLength = length; break; } case "autocopy": updates.autoCopy = value.toLowerCase() === "true"; break; case "savehistory": updates.saveHistory = value.toLowerCase() === "true"; break; case "historylimit": { const limit = parseAndValidateNumber(value, 0, 1e3, "History limit must be between 0 and 1000"); if (limit === null) return; updates.historyLimit = limit; break; } default: consola.error(`āŒ Unknown configuration key: ${key}`); return; } await saveConfig(updates); consola.success(`āœ… Updated ${key} = ${value}`); } async function interactiveConfig() { intro(cyan("āš™ļø Configuration Settings")); const config = await loadConfig(); switch (await select({ message: "What would you like to configure?", options: [ { value: "defaults", label: "šŸŽÆ Default Password Settings" }, { value: "behavior", label: "⚔ Behavior Settings" }, { value: "history", label: "šŸ“š History Settings" }, { value: "show", label: "šŸ‘€ Show Current Config" }, { value: "reset", label: "šŸ”„ Reset to Defaults" } ] })) { case "defaults": await configureDefaults(config); break; case "behavior": await configureBehavior(config); break; case "history": await configureHistory(config); break; case "show": await showConfig(); break; case "reset": await resetConfig(); break; } outro(green("✨ Configuration updated!")); } async function configureDefaults(config) { const length = await text({ message: "Default password length?", placeholder: config.defaultLength.toString(), validate: (value) => { const num = parseInt(value || config.defaultLength.toString()); if (isNaN(num) || num < 4 || num > 128) return "Length must be between 4 and 128"; } }); const uppercase = await confirm({ message: "Include uppercase letters by default?", initialValue: config.defaultIncludeUppercase }); const lowercase = await confirm({ message: "Include lowercase letters by default?", initialValue: config.defaultIncludeLowercase }); const numbers = await confirm({ message: "Include numbers by default?", initialValue: config.defaultIncludeNumbers }); const symbols = await confirm({ message: "Include symbols by default?", initialValue: config.defaultIncludeSymbols }); await saveConfig({ defaultLength: parseInt(length) || config.defaultLength, defaultIncludeUppercase: uppercase, defaultIncludeLowercase: lowercase, defaultIncludeNumbers: numbers, defaultIncludeSymbols: symbols }); } async function configureBehavior(config) { await saveConfig({ autoCopy: await confirm({ message: "Auto-copy passwords to clipboard?", initialValue: config.autoCopy }) }); } async function configureHistory(config) { const saveHistory = await confirm({ message: "Save password history?", initialValue: config.saveHistory }); let historyLimit = config.historyLimit; if (saveHistory) { const limitText = await text({ message: "Maximum history entries?", placeholder: config.historyLimit.toString(), validate: (value) => { const num = parseInt(value || config.historyLimit.toString()); if (isNaN(num) || num < 0 || num > 1e3) return "Limit must be between 0 and 1000"; } }); historyLimit = parseInt(limitText) || config.historyLimit; } await saveConfig({ saveHistory, historyLimit }); } //#endregion //#region src/lib/history.ts const HistoryEntrySchema = z.object({ id: z.string(), password: z.string(), options: z.object({ length: z.number(), includeUppercase: z.boolean(), includeLowercase: z.boolean(), includeNumbers: z.boolean(), includeSymbols: z.boolean(), includeExtraSymbols: z.boolean() }), createdAt: z.string() }); const HistorySchema = z.array(HistoryEntrySchema); const CONFIG_DIR = path.join(os.homedir(), ".gmana"); const HISTORY_FILE = path.join(CONFIG_DIR, "history.json"); async function saveToHistory(password, options) { await fs.ensureDir(CONFIG_DIR); const history = await loadHistory(); const entry = { id: crypto.randomUUID(), password, options: { length: options.length, includeUppercase: options.includeUppercase, includeLowercase: options.includeLowercase, includeNumbers: options.includeNumbers, includeSymbols: options.includeSymbols, includeExtraSymbols: options.includeExtraSymbols }, createdAt: (/* @__PURE__ */ new Date()).toISOString() }; history.unshift(entry); const config = await loadConfig(); const limitedHistory = history.slice(0, config.historyLimit); await fs.writeJson(HISTORY_FILE, limitedHistory, { spaces: 2 }); } async function loadHistory() { try { if (await fs.pathExists(HISTORY_FILE)) { const rawHistory = await fs.readJson(HISTORY_FILE); return HistorySchema.parse(rawHistory); } } catch {} return []; } async function clearHistory() { if (await fs.pathExists(HISTORY_FILE)) await fs.remove(HISTORY_FILE); } //#endregion //#region src/lib/password-generator.ts const PasswordOptionsSchema = z.object({ length: z.number().int().min(4).max(128).default(12), includeUppercase: z.boolean().default(true), includeLowercase: z.boolean().default(true), includeNumbers: z.boolean().default(true), includeSymbols: z.boolean().default(true), includeExtraSymbols: z.boolean().default(false), excludeSimilar: z.boolean().default(false), excludeAmbiguous: z.boolean().default(false), customChars: z.string().optional(), pattern: z.string().optional() }); var PasswordGenerator = class { static LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; static UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; static NUMBERS = "0123456789"; static SYMBOLS = "!@#$%^&*"; static EXTRA_SYMBOLS = "()_+-=[]{}|;:,.<>?"; static SIMILAR_CHARS = "il1Lo0O"; static AMBIGUOUS_CHARS = "{}[]()/\\'\"`~,;.<>"; static generate(options) { const validatedOptions = PasswordOptionsSchema.parse(options); if (validatedOptions.customChars) return this.generateFromCustomChars(validatedOptions.customChars, validatedOptions.length); const charsets = this.buildCharsets(validatedOptions); if (charsets.combined.length === 0) throw new Error("No character types selected for password generation"); return this.generateSecurePassword(charsets, validatedOptions.length); } static filterPattern(set, excludeSimilar, excludeAmbiguous) { let filtered = set; if (excludeSimilar) filtered = filtered.split("").filter((char) => !this.SIMILAR_CHARS.includes(char)).join(""); if (excludeAmbiguous) filtered = filtered.split("").filter((char) => !this.AMBIGUOUS_CHARS.includes(char)).join(""); return filtered; } static buildCharsets(options) { const sets = []; const addSet = (baseSet) => { const filtered = this.filterPattern(baseSet, options.excludeSimilar, options.excludeAmbiguous); if (filtered.length > 0) sets.push(filtered); }; if (options.includeLowercase) addSet(this.LOWERCASE); if (options.includeUppercase) addSet(this.UPPERCASE); if (options.includeNumbers) addSet(this.NUMBERS); if (options.includeSymbols) addSet(this.SYMBOLS); if (options.includeExtraSymbols) addSet(this.EXTRA_SYMBOLS); return { sets, combined: sets.join("") }; } static generateSecurePassword(charsets, length) { const password = []; for (const set of charsets.sets) if (password.length < length) { const randomIndex = crypto$1.randomInt(0, set.length); password.push(set[randomIndex]); } const combinedLength = charsets.combined.length; while (password.length < length) { const randomIndex = crypto$1.randomInt(0, combinedLength); password.push(charsets.combined[randomIndex]); } for (let i = password.length - 1; i > 0; i--) { const j = crypto$1.randomInt(0, i + 1); const temp = password[i]; password[i] = password[j]; password[j] = temp; } return password.join(""); } static generateFromCustomChars(customChars, length) { return this.generateSecurePassword({ sets: [customChars], combined: customChars }, length); } static calculateStrength(password) { let score = 0; const feedback = []; let poolSize = 0; if (/[a-z]/.test(password)) { score += 15; poolSize += 26; } else feedback.push("Add lowercase letters"); if (/[A-Z]/.test(password)) { score += 15; poolSize += 26; } else feedback.push("Add uppercase letters"); if (/\d/.test(password)) { score += 15; poolSize += 10; } else feedback.push("Add numbers"); if (/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.test(password)) { score += 20; if (/[!@#$%^&*]/.test(password)) poolSize += 8; if (/[()_+\-=[\]{}|;:,.<>?]/.test(password)) poolSize += 18; } else feedback.push("Add special characters"); if (poolSize === 0) poolSize = 1; const entropyBits = Math.max(0, Math.round(password.length * Math.log2(poolSize))); if (password.length >= 12) score += 25; else if (password.length >= 8) score += 15; else if (password.length >= 6) score += 10; else feedback.push("Password should be at least 8 characters long"); if (/(.)\1{2,}/.test(password)) { score -= 10; feedback.push("Avoid repeating characters"); } if (/123|abc|qwe/i.test(password)) { score -= 15; feedback.push("Avoid common sequences"); } return { score: Math.max(0, score), level: score >= 90 && entropyBits >= 70 ? "Very Strong" : score >= 75 && entropyBits >= 50 ? "Strong" : score >= 60 && entropyBits >= 40 ? "Good" : score >= 40 ? "Fair" : score >= 20 ? "Weak" : "Very Weak", entropyBits, feedback }; } }; //#endregion //#region src/commands/gen.ts const genCommand = new Command().name("gen").alias("g").description("šŸŽ² Generate a secure password").option("-l, --length <number>", "password length", "12").option("-i, --interactive", "interactive mode", false).option("--no-uppercase", "exclude uppercase letters").option("--no-lowercase", "exclude lowercase letters").option("--no-numbers", "exclude numbers").option("--no-symbols", "exclude symbols").option("--extra-symbols", "include extra symbols").option("--exclude-similar", "exclude similar characters (il1Lo0O)").option("--exclude-ambiguous", "exclude ambiguous characters").option("-c, --copy", "copy to clipboard", true).option("-s, --save", "save to history", false).option("--show-strength", "show password strength", true).action(async (options) => { try { if (options.interactive) await runInteractiveMode(); else await runCommandMode(options); } catch (error) { if (error && typeof error === "object" && "message" in error) consola.error("Generation failed:", error.message); else consola.error("An unexpected error occurred"); process.exit(1); } }); async function runInteractiveMode() { intro(cyan("šŸ” Password Generator")); const length = await text({ message: "Password length?", placeholder: "12", validate: (value) => { const num = parseInt(value || "12"); if (isNaN(num) || num < 4 || num > 128) return "Length must be between 4 and 128"; } }); const charTypes = await multiselect({ message: "Select character types:", options: [ { value: "uppercase", label: "Uppercase (A-Z)", hint: "recommended" }, { value: "lowercase", label: "Lowercase (a-z)", hint: "recommended" }, { value: "numbers", label: "Numbers (0-9)", hint: "recommended" }, { value: "symbols", label: "Symbols (!@#$)", hint: "recommended" }, { value: "extraSymbols", label: "Extra Symbols ([]{}|)", hint: "optional" } ], initialValues: [ "uppercase", "lowercase", "numbers", "symbols" ] }); const excludeOptions = await multiselect({ message: "Exclude characters? (optional)", options: [{ value: "similar", label: "Similar chars (il1Lo0O)" }, { value: "ambiguous", label: "Ambiguous chars ({}[]/\\)" }], required: false }); const copyToClipboard = await confirm({ message: "Copy to clipboard?", initialValue: true }); const saveToHistoryConfirm = await confirm({ message: "Save to history?", initialValue: false }); await generateAndDisplay({ length: parseInt(length) || 12, includeUppercase: charTypes.includes("uppercase"), includeLowercase: charTypes.includes("lowercase"), includeNumbers: charTypes.includes("numbers"), includeSymbols: charTypes.includes("symbols"), includeExtraSymbols: charTypes.includes("extraSymbols"), excludeSimilar: excludeOptions.includes("similar"), excludeAmbiguous: excludeOptions.includes("ambiguous") }, { copy: copyToClipboard, save: saveToHistoryConfirm, showStrength: true }); outro(green("✨ Done!")); } async function runCommandMode(options) { const config = await loadConfig(); await generateAndDisplay({ length: parseInt(options.length || "") || config.defaultLength || 12, includeUppercase: options.uppercase !== false, includeLowercase: options.lowercase !== false, includeNumbers: options.numbers !== false, includeSymbols: options.symbols !== false, includeExtraSymbols: options.extraSymbols ?? false, excludeSimilar: options.excludeSimilar ?? false, excludeAmbiguous: options.excludeAmbiguous ?? false }, { copy: options.copy ?? false, save: options.save ?? false, showStrength: options.showStrength ?? false }); } async function generateAndDisplay(options, actions) { const s = spinner(); s.start("Generating secure password..."); await new Promise((resolve) => setTimeout(resolve, 500)); const password = PasswordGenerator.generate(options); s.stop("Password generated!"); console.log("\n" + bgBlue(white(" Generated Password "))); console.log(bold(white(password))); if (actions.showStrength) { const strength = PasswordGenerator.calculateStrength(password); const strengthColor = strength.score >= 75 ? green : strength.score >= 50 ? yellow : red; console.log(`\n${bold("Strength:")} ${strengthColor(strength.level)} (${strength.score}/100) - ${dim(`Entropy: ${strength.entropyBits} bits`)}`); if (strength.feedback.length > 0) console.log(dim("Suggestions: " + strength.feedback.join(", "))); } if (actions.copy) try { await clipboardy.write(password); consola.success("šŸ“‹ Copied to clipboard!"); } catch { consola.warn("Failed to copy to clipboard"); } if (actions.save) try { await saveToHistory(password, options); consola.success("šŸ’¾ Saved to history!"); } catch { consola.warn("Failed to save to history"); } } //#endregion //#region src/commands/history.ts const historyCommand = new Command().name("history").alias("h").description("šŸ“š Manage password history").option("-l, --list", "list password history").option("-c, --clear", "clear password history").option("--limit <number>", "limit number of entries to show", "10").action(async (options) => { try { if (options.clear) await clearHistoryCommand(); else if (options.list) await listHistory(parseInt(options.limit)); else await interactiveHistory(); } catch (error) { if (error && typeof error === "object" && "message" in error) consola.error("History operation failed:", error.message); else consola.error("An unexpected error occurred"); process.exit(1); } }); async function listHistory(limit = 10) { const history = await loadHistory(); if (history.length === 0) { consola.info("šŸ“­ No password history found"); return; } console.log(cyan(`\nšŸ“š Password History (${Math.min(limit, history.length)} of ${history.length}):`)); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); history.slice(0, limit).forEach((entry, index) => { const date = new Date(entry.createdAt).toLocaleString(); const maskedPassword = maskPassword(entry.password); const options = formatOptions(entry.options); console.log(`\n${bold(`${index + 1}.`)} ${dim(date)}`); console.log(` Password: ${yellow(maskedPassword)}`); console.log(` Settings: ${dim(options)}`); }); console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); } async function clearHistoryCommand() { if (await confirm({ message: "Are you sure you want to clear all password history?" })) { await clearHistory(); consola.success("šŸ—‘ļø Password history cleared"); } else consola.info("Operation cancelled"); } async function interactiveHistory() { intro(cyan("šŸ“š Password History")); const history = await loadHistory(); if (history.length === 0) { consola.info("šŸ“­ No password history found"); outro("Generate some passwords first!"); return; } switch (await select({ message: "What would you like to do?", options: [ { value: "view", label: "šŸ‘€ View History" }, { value: "copy", label: "šŸ“‹ Copy Password" }, { value: "clear", label: "šŸ—‘ļø Clear History" } ] })) { case "view": await listHistory(20); break; case "copy": await copyFromHistory(history); break; case "clear": await clearHistoryCommand(); break; } outro(green("✨ Done!")); } async function copyFromHistory(history) { const selectedId = await select({ message: "Select password to copy:", options: history.slice(0, 10).map((entry) => { const date = new Date(entry.createdAt).toLocaleDateString(); const maskedPassword = maskPassword(entry.password); const settings = formatOptions(entry.options); return { value: entry.id, label: `${maskedPassword} (${date})`, hint: settings }; }) }); const selectedEntry = history.find((entry) => entry.id === selectedId); if (selectedEntry) try { await clipboardy.write(selectedEntry.password); consola.success("šŸ“‹ Password copied to clipboard!"); } catch { consola.error("Failed to copy password to clipboard"); } } function maskPassword(password) { if (password.length <= 4) return "••••"; const start = password.substring(0, 2); const end = password.substring(password.length - 2); return `${start}${"•".repeat(password.length - 4)}${end}`; } function formatOptions(options) { const parts = []; parts.push(`L:${options.length}`); if (options.includeUppercase) parts.push("A-Z"); if (options.includeLowercase) parts.push("a-z"); if (options.includeNumbers) parts.push("0-9"); if (options.includeSymbols) parts.push("!@#"); if (options.includeExtraSymbols) parts.push("[]{}"); return parts.join(" "); } //#endregion //#region src/index.ts async function main() { const program = new Command().name("gmana").description("šŸ” A modern password generator CLI").version(version, "-v, --version", "display version number").helpOption("-h, --help", "display help for command"); program.addCommand(genCommand).addCommand(configCommand).addCommand(historyCommand); program.exitOverride(); try { await program.parseAsync(); } catch (error) { if (error instanceof Error && error.name === "CommanderError") process.exit(0); consola.error("Unexpected error:", error instanceof Error ? error.message : error); process.exit(1); } } main(); //#endregion export {};