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
JavaScript
#!/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 {};