UNPKG

@ideascol/cli-maker

Version:
478 lines (477 loc) 22 kB
"use strict"; 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; }