@ideascol/cli-maker
Version:
A simple library to help create CLIs
478 lines (477 loc) • 22 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSetupCommand = createSetupCommand;
exports.loadSetupConfig = loadSetupConfig;
exports.getRawConfig = getRawConfig;
exports.getConfigValue = getConfigValue;
exports.hiddenPrompt = hiddenPrompt;
exports.prompt = prompt;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const readline_1 = __importDefault(require("readline"));
const crypto_1 = __importDefault(require("crypto"));
const os_1 = __importDefault(require("os"));
const colors_1 = require("./colors");
const interfaces_1 = require("./interfaces");
const validator_1 = require("./command/validator");
/**
* Generates an interactive "setup" command to configure global variables for the CLI.
* Stores the resulting config in ~/.cli-maker/<cliName>-config.json by default.
*/
function createSetupCommand(cliName, options) {
const commandName = options.name || 'setup';
const description = options.description || 'Configure CLI defaults';
const validator = new validator_1.Validator();
const configFile = buildConfigPath(cliName, options.configFileName);
return {
name: commandName,
description,
params: [
{
name: 'interactive',
description: 'Select a single config option to setup interactively',
required: false,
type: interfaces_1.ParamType.Boolean
}
],
action: async (args) => {
let passphrase;
let existingConfig = {};
// Check if config file exists and has encrypted fields
const hasExistingConfig = fs_1.default.existsSync(configFile);
let hasEncryptedFields = false;
if (hasExistingConfig) {
try {
const rawConfig = JSON.parse(fs_1.default.readFileSync(configFile, 'utf-8'));
hasEncryptedFields = Object.values(rawConfig).some((value) => value && typeof value === 'object' && (value.__enc || value.__b64));
}
catch {
// Ignore malformed files
}
}
// Only ask for passphrase if we have encrypted fields or encryption is enabled for new config
if ((hasEncryptedFields || options.encryption?.enabled)) {
if (hasEncryptedFields) {
// Validate passphrase against existing encrypted config
let validPassphrase = false;
while (!validPassphrase) {
passphrase = await askPassphrase(options.encryption?.prompt || 'Passphrase to decrypt existing config');
if (!passphrase) {
console.log(`${colors_1.Colors.Error}❌ Passphrase is required to modify encrypted configuration.${colors_1.Colors.Reset}\n`);
return;
}
const testConfig = loadConfig(configFile, options.steps, passphrase);
// Check if we successfully decrypted at least one encrypted field
const rawConfig = JSON.parse(fs_1.default.readFileSync(configFile, 'utf-8'));
const encryptedKeys = Object.keys(rawConfig).filter(key => {
const value = rawConfig[key];
return value && typeof value === 'object' && (value.__enc || value.__b64);
});
if (encryptedKeys.length > 0) {
// Verify that at least one encrypted field was successfully decrypted
const successfullyDecrypted = encryptedKeys.some(key => testConfig[key] !== undefined && typeof testConfig[key] === 'string');
if (successfullyDecrypted) {
validPassphrase = true;
existingConfig = testConfig;
}
else {
console.log(`${colors_1.Colors.Error}❌ Invalid passphrase. Please try again.${colors_1.Colors.Reset}\n`);
}
}
else {
validPassphrase = true;
existingConfig = testConfig;
}
}
}
else {
// New config with encryption enabled
passphrase = await askPassphrase(options.encryption?.prompt || 'Passphrase for encryption (not stored)');
if (!passphrase) {
console.log(`${colors_1.Colors.Error}❌ Passphrase is required for encrypted configuration.${colors_1.Colors.Reset}\n`);
return;
}
}
}
else {
// No encryption, load config normally
existingConfig = loadConfig(configFile, options.steps, passphrase);
}
const answers = { ...existingConfig };
// Check if interactive single config selection is requested
if (args?.interactive === true) {
console.log(`\n${colors_1.Colors.BgBlue}${colors_1.Colors.FgWhite} SETUP ${colors_1.Colors.Reset} ${colors_1.Colors.FgBlue}Select a configuration to setup${colors_1.Colors.Reset}\n`);
const selectedStep = await selectSingleConfigStep(options.steps, existingConfig);
if (selectedStep) {
const value = await askForStep(selectedStep, answers[selectedStep.name], validator);
if (value !== undefined) {
answers[selectedStep.name] = value;
}
ensureDir(path_1.default.dirname(configFile));
const encoded = encodePasswords(options.steps, answers, passphrase);
fs_1.default.writeFileSync(configFile, JSON.stringify(encoded, null, 2), 'utf-8');
console.log(`\n${colors_1.Colors.Success}✅ Config '${selectedStep.name}' updated in ${configFile}${colors_1.Colors.Reset}\n`);
}
else {
console.log(`\n${colors_1.Colors.Warning}Setup cancelled.${colors_1.Colors.Reset}\n`);
}
}
else {
// Original behavior - setup all configs
console.log(`\n${colors_1.Colors.BgBlue}${colors_1.Colors.FgWhite} SETUP ${colors_1.Colors.Reset} ${colors_1.Colors.FgBlue}Configure your CLI step by step${colors_1.Colors.Reset}\n`);
for (const step of options.steps) {
const value = await askForStep(step, answers[step.name], validator);
if (value !== undefined) {
answers[step.name] = value;
}
}
ensureDir(path_1.default.dirname(configFile));
const encoded = encodePasswords(options.steps, answers, passphrase);
fs_1.default.writeFileSync(configFile, JSON.stringify(encoded, null, 2), 'utf-8');
console.log(`\n${colors_1.Colors.Success}✅ Config stored in ${configFile}${colors_1.Colors.Reset}\n`);
}
if (options.onComplete) {
options.onComplete(answers);
}
}
};
}
/**
* Helper to read the setup config programáticamente.
*/
function loadSetupConfig(cliName, steps, options) {
const configFile = buildConfigPath(cliName, options?.configFileName);
return loadConfig(configFile, steps, options?.passphrase);
}
/**
* Helper to get the raw setup config without decryption.
* Use this for non-sensitive values. For Password fields, use loadSetupConfig with steps and passphrase.
*/
function getRawConfig(cliName, options) {
const configFile = buildConfigPath(cliName, options?.configFileName);
try {
if (fs_1.default.existsSync(configFile)) {
return JSON.parse(fs_1.default.readFileSync(configFile, 'utf-8'));
}
}
catch {
// ignore malformed or unreadable files
}
return {};
}
/**
* Helper to get a specific config value from the setup config.
* This is a simplified version that doesn't require steps and reads the raw config.
* Note: Password fields will remain encrypted/encoded unless you use loadSetupConfig with steps and passphrase.
*/
function getConfigValue(cliName, key, options) {
const config = getRawConfig(cliName, options);
return config[key];
}
function buildConfigPath(cliName, customName) {
const safeName = (customName || `${cliName}-config.json`).replace(/[^a-z0-9._-]/gi, '-');
const home = os_1.default.homedir();
return path_1.default.join(home, '.cli-maker', safeName);
}
function loadConfig(file, steps, passphrase) {
try {
if (fs_1.default.existsSync(file)) {
const raw = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
return decodePasswords(steps, raw, passphrase);
}
}
catch {
// ignore malformed or unreadable files
}
return {};
}
function ensureDir(dir) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
async function askForStep(step, existing, validator) {
let rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
const label = `${colors_1.Colors.Bright}${step.name}${colors_1.Colors.Reset} ${colors_1.Colors.FgGray}(${step.description})${colors_1.Colors.Reset}`;
console.log(label);
if (step.options && step.options.length > 0) {
console.log(`${colors_1.Colors.FgGray}Opciones: ${step.options.join(', ')}${colors_1.Colors.Reset}`);
}
if (existing !== undefined) {
const display = step.type === interfaces_1.ParamType.Password ? '********' : existing;
console.log(`${colors_1.Colors.FgYellow}Actual value:${colors_1.Colors.Reset} ${display}`);
}
else if (step.defaultValue !== undefined) {
const displayDefault = step.type === interfaces_1.ParamType.Password ? '********' : step.defaultValue;
console.log(`${colors_1.Colors.FgYellow}Default value:${colors_1.Colors.Reset} ${displayDefault}`);
}
let finalValue;
let done = false;
while (!done) {
if (step.type === interfaces_1.ParamType.List && step.options && step.options.length > 0) {
const chosen = await askListOption(step, rl);
finalValue = step.options[chosen];
done = true;
}
else {
const prompt = `${colors_1.Colors.FgGreen}>${colors_1.Colors.Reset} `;
let input;
if (step.type === interfaces_1.ParamType.Password) {
// Close readline to avoid interference with raw mode
rl.close();
input = await readHiddenInput(prompt);
// Recreate readline for potential next iterations
rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
}
else {
input = await ask(prompt);
}
const candidateRaw = input === '' ? (existing ?? step.defaultValue ?? '') : input;
const candidate = typeof candidateRaw === 'boolean' ? String(candidateRaw) : candidateRaw;
const validation = validator.validateParam(candidate, step.type, step.required, step.options, step.name);
if (validation.error) {
console.log(validation.error);
}
else {
finalValue = validation.value;
done = true;
}
}
}
rl.close();
return finalValue;
}
async function selectSingleConfigStep(steps, existingConfig) {
console.log(`${colors_1.Colors.FgYellow}Available configuration options:${colors_1.Colors.Reset}\n`);
console.log(`${colors_1.Colors.FgGray}Use ↑/↓ arrows to navigate, Enter to select, or 'q' to cancel${colors_1.Colors.Reset}\n`);
const options = [...steps, { name: 'Cancel', description: 'Exit setup', type: interfaces_1.ParamType.Text }];
let selectedIndex = 0;
const renderMenu = () => {
// Clear previous menu
process.stdout.write('\x1b[2J\x1b[H');
console.log(`${colors_1.Colors.FgYellow}Available configuration options:${colors_1.Colors.Reset}\n`);
console.log(`${colors_1.Colors.FgGray}Use ↑/↓ arrows to navigate, Enter to select, or 'q' to cancel${colors_1.Colors.Reset}\n`);
options.forEach((option, idx) => {
const isSelected = idx === selectedIndex;
const prefix = isSelected ? `${colors_1.Colors.BgBlue}${colors_1.Colors.FgWhite} ▶ ${colors_1.Colors.Reset}` : ' ';
if (idx < steps.length) {
const step = steps[idx];
const hasValue = existingConfig[step.name] !== undefined;
const statusIcon = hasValue ? `${colors_1.Colors.FgGreen}✓${colors_1.Colors.Reset}` : `${colors_1.Colors.FgGray}○${colors_1.Colors.Reset}`;
const valueDisplay = hasValue ?
(step.type === interfaces_1.ParamType.Password ? ' (configured)' : ` (${existingConfig[step.name]})`) :
' (not set)';
const highlight = isSelected ? `${colors_1.Colors.BgBlue}${colors_1.Colors.FgWhite}` : '';
const resetColor = isSelected ? `${colors_1.Colors.Reset}` : '';
console.log(`${prefix}${highlight}${statusIcon} ${colors_1.Colors.Bright}${step.name}${colors_1.Colors.Reset}${highlight} - ${step.description}${colors_1.Colors.FgGray}${valueDisplay}${resetColor}${colors_1.Colors.Reset}`);
}
else {
// Cancel option
const highlight = isSelected ? `${colors_1.Colors.BgRed}${colors_1.Colors.FgWhite}` : `${colors_1.Colors.FgRed}`;
const resetColor = isSelected ? `${colors_1.Colors.Reset}` : `${colors_1.Colors.Reset}`;
console.log(`${prefix}${highlight}Cancel${resetColor}`);
}
});
console.log(`\n${colors_1.Colors.FgGray}Selected: ${options[selectedIndex].name}${colors_1.Colors.Reset}`);
};
return new Promise((resolve) => {
// Enable raw mode for key detection
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
renderMenu();
const onKeyPress = (key) => {
switch (key) {
case '\u001b[A': // Up arrow
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : options.length - 1;
renderMenu();
break;
case '\u001b[B': // Down arrow
selectedIndex = selectedIndex < options.length - 1 ? selectedIndex + 1 : 0;
renderMenu();
break;
case '\r': // Enter
process.stdin.setRawMode(false);
process.stdin.removeListener('data', onKeyPress);
process.stdin.pause();
if (selectedIndex === steps.length) {
resolve(null); // Cancel selected
}
else {
resolve(steps[selectedIndex]);
}
break;
case 'q':
case 'Q':
case '\u0003': // Ctrl+C
process.stdin.setRawMode(false);
process.stdin.removeListener('data', onKeyPress);
process.stdin.pause();
resolve(null);
break;
default:
// Ignore other keys
break;
}
};
process.stdin.on('data', onKeyPress);
});
}
async function askListOption(step, rl) {
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
step.options = step.options || [];
step.options.forEach((opt, idx) => {
console.log(` ${colors_1.Colors.FgCyan}${idx + 1}.${colors_1.Colors.Reset} ${opt}`);
});
let selected = -1;
while (selected < 0 || selected >= step.options.length) {
const answer = await ask(`${colors_1.Colors.FgGreen}Select option (1-${step.options.length}):${colors_1.Colors.Reset} `);
const num = parseInt(answer, 10);
if (!isNaN(num) && num >= 1 && num <= step.options.length) {
selected = num - 1;
}
else {
console.log(`${colors_1.Colors.Warning}Invalid option.${colors_1.Colors.Reset}`);
}
}
return selected;
}
function encodePasswords(steps, data, passphrase) {
const result = { ...data };
steps.forEach(step => {
if (step.type === interfaces_1.ParamType.Password && result[step.name] !== undefined) {
const str = String(result[step.name]);
if (passphrase) {
result[step.name] = encryptWithPassphrase(str, passphrase);
}
else {
result[step.name] = { __b64: true, value: Buffer.from(str, 'utf-8').toString('base64') };
}
}
});
return result;
}
function decodePasswords(steps, data, passphrase) {
const result = { ...data };
steps.forEach(step => {
const value = result[step.name];
if (step.type === interfaces_1.ParamType.Password && value && typeof value === 'object') {
if (value.__enc && value.data && passphrase) {
const decrypted = decryptWithPassphrase(value, passphrase);
result[step.name] = decrypted;
}
else if (value.__b64 && value.value) {
try {
result[step.name] = Buffer.from(value.value, 'base64').toString('utf-8');
}
catch {
result[step.name] = undefined;
}
}
}
});
return result;
}
/**
* Prompts the user for hidden input (password-like).
* The input is not displayed on the screen.
*/
async function hiddenPrompt(prompt) {
return readHiddenInput(prompt);
}
/**
* Prompts the user for visible input.
*/
async function prompt(question) {
return new Promise(resolve => {
const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, answer => {
rl.close();
resolve(answer);
});
});
}
async function readHiddenInput(prompt) {
return new Promise(resolve => {
const stdin = process.stdin;
const stdout = process.stdout;
stdout.write(prompt);
const onData = (char) => {
const key = char.toString();
if (key === '\n' || key === '\r' || key === '\u0004') {
stdout.write('\n');
stdin.removeListener('data', onData);
if (stdin.isTTY)
stdin.setRawMode(false);
stdin.pause();
resolve(buffer);
}
else if (key === '\u0003') {
// Ctrl+C
stdin.removeListener('data', onData);
if (stdin.isTTY)
stdin.setRawMode(false);
stdin.pause();
process.exit(0);
}
else if (key === '\u007f' || key === '\b') {
// Backspace/Delete
if (buffer.length > 0) {
buffer = buffer.slice(0, -1);
stdout.write('\b \b');
}
}
else {
const printableChars = key.split('').filter(c => c >= ' ' && c <= '~').join('');
if (printableChars.length > 0) {
buffer += printableChars;
stdout.write('*'.repeat(printableChars.length));
}
}
};
let buffer = '';
stdin.resume();
if (stdin.isTTY) {
stdin.setRawMode(true);
}
stdin.on('data', onData);
});
}
function encryptWithPassphrase(plain, passphrase) {
const salt = crypto_1.default.randomBytes(16);
const iv = crypto_1.default.randomBytes(12);
const key = crypto_1.default.pbkdf2Sync(passphrase, salt, 120000, 32, 'sha256');
const cipher = crypto_1.default.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
__enc: true,
alg: 'aes-256-gcm',
iv: iv.toString('base64'),
salt: salt.toString('base64'),
tag: tag.toString('base64'),
data: encrypted.toString('base64'),
};
}
function decryptWithPassphrase(payload, passphrase) {
try {
const salt = Buffer.from(payload.salt, 'base64');
const iv = Buffer.from(payload.iv, 'base64');
const tag = Buffer.from(payload.tag, 'base64');
const data = Buffer.from(payload.data, 'base64');
const key = crypto_1.default.pbkdf2Sync(passphrase, salt, 120000, 32, 'sha256');
const decipher = crypto_1.default.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
return decrypted.toString('utf8');
}
catch {
return undefined;
}
}
async function askPassphrase(promptText) {
const pass = await readHiddenInput(`${colors_1.Colors.FgCyan}${promptText}:${colors_1.Colors.Reset} `);
return pass || undefined;
}