UNPKG

mnee-cli

Version:

A CLI tool for interacting with MNEE USD

1,125 lines 56.9 kB
#!/usr/bin/env node import { Command } from 'commander'; import inquirer from 'inquirer'; import crypto from 'crypto'; import { PrivateKey, Utils } from '@bsv/sdk'; import { decryptPrivateKey, encryptPrivateKey } from './utils/crypto.js'; import { getActiveWallet, getAllWallets, saveWallets, setActiveWallet, getWalletByAddress, setPrivateKey, deletePrivateKey, getPrivateKey, clearActiveWallet, getLegacyWallet, deleteLegacyWallet, } from './utils/keytar.js'; import { getVersion } from './utils/helper.js'; import { colors, icons, createSpinner, showBox, formatAddress, formatAmount, formatLink, showWelcome, animateSuccess, startTransactionAnimation, startAirdropAnimation, } from './utils/ui.js'; import Mnee from '@mnee/ts-sdk'; import { loadConfig, saveConfig, clearConfig, startAuthFlow, getProfile, logout as logoutApi } from './utils/auth.js'; const apiUrl = 'https://api-developer.mnee.net'; // Use https://api-stg-developer.mnee.net if testing in mnee stage env (need VPN to access) const getMneeInstance = (environment, apiKey) => { return new Mnee({ environment, apiKey }); }; const getTxStatus = async (mneeInstance, ticketId) => { return await mneeInstance.getTxStatus(ticketId); }; const pollForTxStatus = async (mneeInstance, ticketId, onStatusUpdate) => { const maxAttempts = 60; // 5 minutes with 5 second intervals let attempts = 0; let lastStatus = null; while (attempts < maxAttempts) { try { const status = await getTxStatus(mneeInstance, ticketId); // Call the optional callback with status updates if (onStatusUpdate && status.status !== lastStatus) { onStatusUpdate(status); lastStatus = status.status; } // Return when status is no longer BROADCASTING if (status.status !== 'BROADCASTING') { return status; } // Wait 5 seconds before next poll await new Promise((resolve) => setTimeout(resolve, 1000)); attempts++; } catch (error) { console.error('Error polling transaction status:', error); throw error; } } throw new Error('Transaction status polling timed out after 5 minutes'); }; const safePrompt = async (questions) => { try { return await inquirer.prompt(questions); } catch { console.log(`\n${icons.error} ${colors.error('Operation cancelled by user.')}`); process.exit(1); } }; const program = new Command(); if (!process.argv.slice(2).length) { await showWelcome(); process.exit(0); // Exit after showing welcome, don't show help } program .name('mnee') .description(colors.muted('CLI for interacting with MNEE tokens')) .version(getVersion()) .configureHelp({ sortSubcommands: true, subcommandTerm: (cmd) => cmd.name() + ' ' + cmd.usage(), }) .addHelpText('before', `\n${colors.highlight('MNEE CLI')} ${colors.muted(`v${getVersion()}`)}\n`) .addHelpText('after', `\n${colors.muted('Examples:')}\n` + ` ${colors.primary('mnee create')} ${colors.muted('# Create a new wallet')}\n` + ` ${colors.primary('mnee balance')} ${colors.muted('# Check wallet balance')}\n` + ` ${colors.primary('mnee transfer 10 1A...')} ${colors.muted('# Quick transfer')}\n` + ` ${colors.primary('mnee list')} ${colors.muted('# List all wallets')}\n\n` + `${colors.muted('For more help:')} ${colors.primary('mnee <command> --help')}\n`); // Add error handling for the main program program.exitOverride((err) => { if (err.code === 'commander.help') { process.exit(0); } process.exit(err.exitCode); }); program .command('create') .description('Generate a new wallet and store keys securely') .option('-s, --sandbox', 'Create a sandbox wallet') .option('-p, --production', 'Create a production wallet') .action(async (options) => { try { const existingWallets = await getAllWallets(); // Determine environment from options or prompt let environment; if (options.sandbox) { environment = 'sandbox'; } else if (options.production) { environment = 'production'; } else { const result = await safePrompt([ { type: 'list', name: 'environment', message: 'Select wallet environment:', choices: [ { name: 'Production', value: 'production' }, { name: 'Sandbox', value: 'sandbox' }, ], default: 'production', }, ]); environment = result.environment; } const { walletName } = await safePrompt([ { type: 'input', name: 'walletName', message: `Enter a name for your ${environment} wallet:`, default: `${environment}-wallet-${Date.now()}`, validate: (input) => { const validation = validateWalletName(input); if (!validation.isValid) { return validation.error || 'Invalid wallet name'; } if (existingWallets.some((w) => w.name.toLowerCase() === input.toLowerCase())) { return `A wallet with name "${input}" already exists (names are case-insensitive)`; } return true; }, }, ]); const entropy = crypto.randomBytes(32); const privateKey = PrivateKey.fromString(entropy.toString('hex')); const address = privateKey.toAddress(); const { password, confirmPassword } = await safePrompt([ { type: 'password', name: 'password', message: 'Set a password for your wallet:', mask: '*', validate: (input) => { if (input.length < 8) { return 'Password must be at least 8 characters long'; } // Check for at least one uppercase letter if (!/[A-Z]/.test(input)) { return 'Password must contain at least one uppercase letter'; } // Check for at least one lowercase letter if (!/[a-z]/.test(input)) { return 'Password must contain at least one lowercase letter'; } // Check for at least one number if (!/[0-9]/.test(input)) { return 'Password must contain at least one number'; } // Check for at least one special character if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(input)) { return 'Password must contain at least one special character'; } return true; }, }, { type: 'password', name: 'confirmPassword', message: 'Confirm your password:', mask: '*', }, ]); if (password !== confirmPassword) { console.error('❌ Passwords do not match. Try again.'); return; } const encryptedKey = encryptPrivateKey(privateKey.toString(), password); const wallets = await getAllWallets(); wallets.forEach((wallet) => { wallet.isActive = false; }); const newWallet = { address, environment, name: walletName, isActive: true, }; wallets.push(newWallet); await saveWallets(wallets); await setPrivateKey(address, encryptedKey); await setActiveWallet(newWallet); animateSuccess('Wallet created successfully!'); setTimeout(() => { showBox(`${icons.wallet} ${colors.highlight('Wallet Details')}\n\n` + `${icons.dot} Name: ${colors.primary(walletName)}\n` + `${icons.dot} Environment: ${environment === 'production' ? colors.success(environment) : colors.warning(environment)}\n` + `${icons.dot} Address: ${colors.muted(address)}\n\n` + `${icons.check} ${colors.success('This wallet is now active')}`, 'New Wallet Created', 'success'); }, 1200); } catch (error) { console.error(`\n${icons.error} ${colors.error('Error creating wallet:')}`, error); } }); program .command('address') .description('Retrieve your wallet address') .action(async () => { const activeWallet = await getActiveWallet(); if (!activeWallet) { console.error(`${icons.error} ${colors.error('No active wallet found.')} Run ${colors.primary('mnee create')} first or ${colors.primary('mnee use <wallet-name>')} to select a wallet.`); return; } showBox(`${icons.wallet} ${colors.highlight('Active Wallet')}\n\n` + `${icons.dot} Name: ${colors.primary(activeWallet.name)}\n` + `${icons.dot} Environment: ${activeWallet.environment === 'production' ? colors.success(activeWallet.environment) : colors.warning(activeWallet.environment)}\n` + `${icons.dot} Address: ${colors.muted(activeWallet.address)}`, 'Wallet Address', 'info'); }); program .command('balance') .description('Get the balance of the wallet') .action(async () => { const activeWallet = await getActiveWallet(); if (!activeWallet) { console.error(`${icons.error} ${colors.error('No active wallet found.')} Run ${colors.primary('mnee create')} first or ${colors.primary('mnee use <wallet-name>')} to select a wallet.`); return; } const spinner = createSpinner(`Fetching balance for ${colors.primary(activeWallet.name)} (${activeWallet.environment})...`); spinner.start(); try { const mneeInstance = getMneeInstance(activeWallet.environment); const { decimalAmount } = await mneeInstance.balance(activeWallet.address); spinner.succeed(`Balance retrieved!`); showBox(`${icons.money} ${colors.highlight('Wallet Balance')}\n\n` + `${formatAmount(decimalAmount)}\n\n` + `${icons.wallet} ${colors.muted(activeWallet.name)}\n` + `${icons.arrow} ${colors.muted(formatAddress(activeWallet.address))}`, 'Balance', 'success'); } catch (error) { spinner.fail(colors.error('Error fetching balance')); console.error(error); } }); program .command('history') .description('Get transaction history with filtering options') .option('-u, --unconfirmed', 'Show only unconfirmed transactions') .option('-c, --confirmed', 'Show only confirmed transactions') .option('-l, --limit <number>', 'Show only the N most recent transactions (e.g., -l 10)', parseInt) .option('-t, --type <type>', 'Filter by type: "send" or "receive"') .option('--txid <txid>', 'Search by transaction ID (partial match)') .option('--address <address>', 'Filter by counterparty address (partial match)') .option('--min <amount>', 'Show transactions >= amount (e.g., --min 0.5)', parseFloat) .option('--max <amount>', 'Show transactions <= amount (e.g., --max 100)', parseFloat) .action(async (options) => { const activeWallet = await getActiveWallet(); if (!activeWallet) { console.error(`${icons.error} ${colors.error('No active wallet found.')} Run ${colors.primary('mnee create')} first or ${colors.primary('mnee use <wallet-name>')} to select a wallet.`); return; } const spinner = createSpinner(`Fetching history for ${colors.primary(activeWallet.name)} (${activeWallet.environment})...`); spinner.start(); try { const mneeInstance = getMneeInstance(activeWallet.environment); let nextScore = undefined; let hasMore = true; let history = []; let attempts = 0; const maxAttempts = 20; // Safety limit to prevent infinite loops while (hasMore && attempts < maxAttempts) { const { history: newHistory, nextScore: newNextScore } = await mneeInstance.recentTxHistory(activeWallet.address, nextScore, 100, 'desc'); if (newNextScore === nextScore && newNextScore !== undefined) break; history.push(...newHistory); nextScore = newNextScore; hasMore = nextScore !== 0 && nextScore !== undefined; attempts++; } if (attempts >= maxAttempts) { console.log('Reached maximum number of attempts. Some history may be missing.'); } // Deduplicate transactions by txid (keep the one with the highest score) const txMap = new Map(); history.forEach((tx) => { const existing = txMap.get(tx.txid); if (!existing || tx.score > existing.score) { txMap.set(tx.txid, tx); } }); history = Array.from(txMap.values()); // Apply filters based on options if (options.unconfirmed) { history = history.filter((tx) => tx.status === 'unconfirmed'); } else if (options.confirmed) { history = history.filter((tx) => tx.status === 'confirmed'); } // Filter by transaction type if (options.type) { const type = options.type.toLowerCase(); if (type === 'send' || type === 'receive') { history = history.filter((tx) => tx.type === type); } } // Filter by transaction ID (partial match) if (options.txid) { history = history.filter((tx) => tx.txid.toLowerCase().includes(options.txid.toLowerCase())); } // Filter by counterparty address if (options.address) { history = history.filter((tx) => tx.counterparties?.some((cp) => cp.address.toLowerCase().includes(options.address.toLowerCase()))); } // Filter by amount range if (options.min !== undefined || options.max !== undefined) { history = history.filter((tx) => { const amount = mneeInstance.fromAtomicAmount(tx.amount || 0); if (options.min !== undefined && amount < options.min) return false; if (options.max !== undefined && amount > options.max) return false; return true; }); } // Apply limit if specified if (options.limit && options.limit > 0) { history = history.slice(0, options.limit); } spinner.stop(); // Display formatted history if (history.length === 0) { showBox(`${icons.info} No transactions found`, 'Transaction History', 'info'); } else { // Sort transactions by score (newest first) history.sort((a, b) => (b.score || 0) - (a.score || 0)); console.log(''); console.log(colors.highlight(`${icons.time} Transaction History`)); console.log(colors.muted('─'.repeat(60))); console.log(''); // Display transactions history.forEach((tx, index) => { // Transaction type and styling const type = tx.type || 'unknown'; const icon = type === 'send' ? icons.send : type === 'receive' ? icons.receive : icons.dot; const color = type === 'send' ? colors.error : type === 'receive' ? colors.success : colors.muted; // Convert amount and fee from atomic units const amount = mneeInstance.fromAtomicAmount(tx.amount || 0); const fee = mneeInstance.fromAtomicAmount(tx.fee || 0); // Status indicator const statusIcon = tx.status === 'confirmed' ? colors.success('✓') : colors.warning('⏳'); const statusText = tx.status === 'confirmed' ? colors.muted('confirmed') : colors.warning('unconfirmed'); // Block height const heightDisplay = tx.height ? `block ${tx.height}` : ''; // Format the main transaction line console.log(` ${icon} ${color(type.toUpperCase().padEnd(8))} ${formatAmount(amount).padEnd(22)} ${statusIcon} ${statusText}`); // Show fee if it exists if (fee > 0) { console.log(` ${colors.muted('fee:')} ${formatAmount(fee)}`); } // Show all counterparties if (tx.counterparties && tx.counterparties.length > 0) { tx.counterparties.forEach((cp) => { const cpAmount = mneeInstance.fromAtomicAmount(cp.amount || 0); console.log(` ${colors.muted(type === 'send' ? 'to:' : 'from:')} ${colors.muted(cp.address)} ${formatAmount(cpAmount)}`); }); } // Show block height if (heightDisplay) { console.log(` ${colors.muted(heightDisplay)}`); } // Show transaction ID console.log(` ${colors.muted(`tx: ${tx.txid}`)}`); // Add separator between transactions (except for the last one) if (index < history.length - 1) { console.log(colors.muted(' ' + '·'.repeat(50))); } console.log(''); }); console.log(colors.muted('─'.repeat(60))); console.log(colors.muted(` Total: ${history.length} transaction${history.length !== 1 ? 's' : ''}`)); console.log(''); } } catch (error) { spinner.fail(colors.error('Error fetching history')); console.error(error); } }); program .command('transfer [amount] [address]') .description('Transfer MNEE to another address') .action(async (amount, address) => { try { const activeWallet = await getActiveWallet(); if (!activeWallet) { console.error(`${icons.error} ${colors.error('No active wallet found.')} Run ${colors.primary('mnee create')} first or ${colors.primary('mnee use <wallet-name>')} to select a wallet.`); return; } // Validate amount if provided as argument if (amount) { const validateAmount = (input) => { const trimmed = input.trim(); if (!trimmed) return 'Amount is required'; const validNumberRegex = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; if (!validNumberRegex.test(trimmed)) { return 'Invalid amount. Please enter a valid number (e.g., 10, 10.5, 1.5e-3)'; } const num = parseFloat(trimmed); if (isNaN(num)) return 'Invalid amount. Please enter a valid number'; if (num <= 0) return 'Amount must be greater than 0'; if (num < 0.00001) return 'Amount must be at least 0.00001 MNEE'; return true; }; const validation = validateAmount(amount); if (validation !== true) { console.error(`${icons.error} ${colors.error(validation)}`); return; } } // Validate address if provided as argument if (address) { const validation = validateBSVAddress(address); if (validation !== true) { console.error(`${icons.error} ${colors.error(validation)}`); return; } } // Prompt for amount and/or address if not provided let transferAmount = amount; let toAddress = address; if (!amount || !address) { const prompts = []; if (!amount) { prompts.push({ type: 'input', name: 'amount', message: 'Enter the amount to transfer:', validate: (input) => { const trimmed = input.trim(); if (!trimmed) return 'Amount is required'; const validNumberRegex = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; if (!validNumberRegex.test(trimmed)) { return 'Invalid amount. Please enter a valid number (e.g., 10, 10.5, 1.5e-3)'; } const num = parseFloat(trimmed); if (isNaN(num)) return 'Invalid amount. Please enter a valid number'; if (num <= 0) return 'Amount must be greater than 0'; if (num < 0.00001) return 'Amount must be at least 0.00001 MNEE'; return true; }, }); } if (!address) { prompts.push({ type: 'input', name: 'toAddress', message: "Enter the recipient's address:", validate: validateBSVAddress, }); } const answers = await safePrompt(prompts); transferAmount = amount || answers.amount; toAddress = address || answers.toAddress; } const { password } = await safePrompt([ { type: 'password', name: 'password', message: 'Enter your wallet password:', mask: '*', }, ]); const encryptedKey = await getPrivateKey(activeWallet.address); if (!encryptedKey) { console.error('❌ Private key not found for this wallet.'); return; } const privateKeyHex = decryptPrivateKey(encryptedKey, password); if (!privateKeyHex) { console.error('❌ Incorrect password! Decryption failed.'); return; } const privateKey = PrivateKey.fromString(privateKeyHex); const request = [{ address: toAddress, amount: parseFloat(transferAmount) }]; const spinner = createSpinner(`${icons.send} Initiating transfer from ${colors.primary(activeWallet.name)}...`); spinner.start(); try { const mneeInstance = getMneeInstance(activeWallet.environment); // Explicitly set broadcast to true to ensure we get a ticketId const response = await mneeInstance.transfer(request, privateKey.toWif()); // Check what type of response we got if (response.ticketId) { // We got a ticket ID, poll for status spinner.stop(); // Show initial success message console.log(`${colors.success('✓')} ${colors.primary('Transfer initiated!')} ${colors.muted(`Ticket: ${response.ticketId}`)}`); // Start looping transaction animation const txAnim = startTransactionAnimation(); // Poll for transaction status const finalStatus = await pollForTxStatus(mneeInstance, response.ticketId); if (finalStatus.status === 'SUCCESS' || finalStatus.status === 'MINED') { // Stop animation with success txAnim.stop(true); setTimeout(() => { showBox(`${icons.check} ${colors.highlight('Transaction Details')}\n\n` + `${icons.dot} Amount: ${formatAmount(transferAmount)}\n` + `${icons.dot} To: ${colors.muted(formatAddress(toAddress))}\n` + `${icons.dot} TX ID: ${colors.muted(finalStatus.tx_id)}\n\n` + `View on WhatsOnChain:\n` + formatLink(`https://whatsonchain.com/tx/${finalStatus.tx_id}?tab=m8eqcrbs`), 'Transfer Success', 'success'); }, 1200); } else if (finalStatus.status === 'FAILED') { // Stop animation without success txAnim.stop(false); showBox(`${icons.error} ${colors.error('Transaction failed')}\n\n` + `${finalStatus.errors || 'Unknown error'}`, 'Transfer Failed', 'error'); } } else if (response.rawtx) { // We got a raw transaction instead (shouldn't happen with broadcast: true) spinner.succeed('Transaction created.'); showBox(`${icons.warning} ${colors.warning('Raw transaction returned')}\n\n` + `This might indicate the transaction needs to be submitted manually.\n\n` + `${colors.muted('Raw TX:')} ${response.rawtx.substring(0, 50)}...`, 'Warning', 'warning'); } else { // No valid response spinner.fail('Transfer failed. No ticket ID or transaction returned.'); } } catch (error) { console.log(error); spinner.fail(`Transfer failed. ${error && error.message ? error.message.includes('status: 423') ? 'The sending or receiving address may be frozen or blacklisted. Please visit https://mnee.io and contact support for questions or concerns.' : 'Please try again.' : 'Please try again.'}`); process.exit(1); } } catch (error) { console.log(`\n${icons.error} ${colors.error('Operation interrupted.')}`); process.exit(1); } }); program .command('status <ticketId>') .description('Check the status of a transaction using its ticket ID') .action(async (ticketId) => { try { const activeWallet = await getActiveWallet(); if (!activeWallet) { console.error(`${icons.error} ${colors.error('No active wallet found.')} Run ${colors.primary('mnee create')} first or ${colors.primary('mnee use <wallet-name>')} to select a wallet.`); return; } const spinner = createSpinner(`Checking status for ticket: ${colors.primary(ticketId)}...`); spinner.start(); try { const mneeInstance = getMneeInstance(activeWallet.environment); const status = await getTxStatus(mneeInstance, ticketId); spinner.stop(); const statusColor = { BROADCASTING: colors.warning, SUCCESS: colors.success, MINED: colors.success, FAILED: colors.error, }[status.status] || colors.info; const statusIcon = { BROADCASTING: icons.time, SUCCESS: icons.success, MINED: '⛏️', FAILED: icons.error, }[status.status] || icons.info; let content = `${statusIcon} ${colors.highlight('Transaction Status')}\n\n` + `${icons.dot} Ticket ID: ${colors.muted(status.id)}\n` + `${icons.dot} Status: ${statusColor(status.status)}\n`; if (status.tx_id) { content += `${icons.dot} TX ID: ${colors.muted(status.tx_id)}\n\n`; content += `View on WhatsOnChain:\n${formatLink(`https://whatsonchain.com/tx/${status.tx_id}?tab=m8eqcrbs`)}\n`; } content += `\n${icons.dot} Created: ${colors.muted(new Date(status.createdAt).toLocaleString())}\n`; content += `${icons.dot} Updated: ${colors.muted(new Date(status.updatedAt).toLocaleString())}`; if (status.errors) { content += `\n\n${icons.warning} ${colors.error('Errors:')} ${status.errors}`; } const boxType = status.status === 'FAILED' ? 'error' : status.status === 'BROADCASTING' ? 'warning' : 'success'; showBox(content, 'Transaction Status', boxType); } catch (error) { spinner.fail(`Error checking status: ${error.message || 'Unknown error'}`); } } catch (error) { console.error(`\n${icons.error} ${colors.error('Error:')}`, error); } }); program .command('export') .description('Decrypt and retrieve your private key in WIF format') .action(async () => { try { const activeWallet = await getActiveWallet(); if (!activeWallet) { console.error(`${icons.error} ${colors.error('No active wallet found.')} Run ${colors.primary('mnee create')} first or ${colors.primary('mnee use <wallet-name>')} to select a wallet.`); return; } const { password } = await safePrompt([ { type: 'password', name: 'password', message: 'Enter your wallet password:', mask: '*', }, ]); const encryptedKey = await getPrivateKey(activeWallet.address); if (!encryptedKey) { console.error('❌ Private key not found for this wallet.'); return; } const { confirm } = await safePrompt([ { type: 'confirm', name: 'confirm', message: 'You are about to expose your private key. Continue?', default: false, }, ]); if (!confirm) { console.log('🚫 Operation cancelled.'); return; } const privateKeyHex = decryptPrivateKey(encryptedKey, password); if (!privateKeyHex) { console.error('❌ Incorrect password! Decryption failed.'); return; } const privateKey = PrivateKey.fromString(privateKeyHex); const wif = privateKey.toWif(); showBox(`${icons.key} ${colors.highlight('Private Key Export')}\n\n` + `${icons.wallet} Wallet: ${colors.primary(activeWallet.name)}\n` + `${icons.dot} Environment: ${activeWallet.environment === 'production' ? colors.success(activeWallet.environment) : colors.warning(activeWallet.environment)}\n` + `${icons.dot} Address: ${colors.muted(activeWallet.address)}\n\n` + `${icons.lock} ${colors.warning('WIF Private Key:')}\n` + `${colors.muted(wif)}\n\n` + `${icons.warning} ${colors.error(' KEEP THIS KEY SAFE!')}\n` + `${colors.error('Never share it with anyone!')}`, 'Private Key', 'warning'); } catch (error) { console.error(`\n${icons.error} ${colors.error('Error exporting private key:')}`, error); } }); program .command('delete <walletName>') .description('Delete a wallet') .action(async (walletName) => { try { const wallets = await getAllWallets(); const activeWallet = await getActiveWallet(); if (wallets.length === 0) { console.error('❌ No wallets found.'); return; } if (!walletName && activeWallet) { walletName = activeWallet.name; } if (!walletName) { console.error('❌ No wallet specified and no active wallet found.'); return; } const wallet = wallets.find((w) => w.name === walletName); if (!wallet) { console.error(`❌ Wallet "${walletName}" not found.`); return; } const { confirm } = await safePrompt([ { type: 'confirm', name: 'confirm', message: `Are you sure you want to delete wallet "${walletName}"? This action cannot be undone.`, default: false, }, ]); if (!confirm) { console.log('🚫 Operation cancelled.'); return; } const encryptedKey = await getPrivateKey(wallet.address); if (!encryptedKey) { console.error('❌ Private key not found for this wallet.'); return; } const { password } = await safePrompt([ { type: 'password', name: 'password', message: 'Enter your wallet password to confirm deletion:', mask: '*', }, ]); let decryptedKey = null; try { decryptedKey = decryptPrivateKey(encryptedKey, password); } catch (error) { console.error('❌ Incorrect password! Deletion cancelled.'); return; } if (!decryptedKey) { console.error('❌ Password verification failed. Deletion cancelled.'); return; } const updatedWallets = wallets.filter((w) => w.name !== walletName); if (wallet.isActive) { if (updatedWallets.length > 0) { updatedWallets[0].isActive = true; await setActiveWallet(updatedWallets[0]); console.log(`\n✅ Active wallet switched to: ${updatedWallets[0].name}`); } else { await clearActiveWallet(); console.log('\nℹ️ No active wallet set. Create a new wallet with `mnee create`.'); } } // Delete the wallet's private key first await deletePrivateKey(wallet.address); // Then update the wallets list await saveWallets(updatedWallets); animateSuccess(`Wallet "${walletName}" deleted successfully!`); } catch (error) { console.error(`\n${icons.error} ${colors.error('Error deleting wallet:')}`, error); } }); program .command('list') .description('List and switch between your wallets') .action(async () => { try { const wallets = await getAllWallets(); if (wallets.length === 0) { console.log('\n❌ No wallets found. Run `mnee create` to create a wallet.'); return; } // Sort wallets: production first, then sandbox const sortedWallets = [...wallets].sort((a, b) => { if (a.environment === 'production' && b.environment === 'sandbox') return -1; if (a.environment === 'sandbox' && b.environment === 'production') return 1; return 0; }); // Go straight to wallet selection const choices = []; let lastEnv = null; // Find the longest wallet name for proper padding const maxNameLength = Math.max(...sortedWallets.map((w) => w.name.length)); sortedWallets.forEach((wallet) => { // Add separator when switching from production to sandbox if (lastEnv === 'production' && wallet.environment === 'sandbox') { choices.push(new inquirer.Separator(colors.muted('\n──── Sandbox Wallets ────'))); } else if (lastEnv === null && wallet.environment === 'production') { choices.push(new inquirer.Separator(colors.muted('──── Production Wallets ────'))); } else if (lastEnv === null && wallet.environment === 'sandbox') { choices.push(new inquirer.Separator(colors.muted('──── Sandbox Wallets ────'))); } const envIcon = '●'; const envColor = wallet.environment === 'production' ? colors.success : colors.warning; const envLabel = wallet.environment === 'production' ? colors.success('[PROD]') : colors.warning('[TEST]'); const activeLabel = wallet.isActive ? colors.cyan(' ← current') : ''; const paddedName = wallet.name + ' '.repeat(Math.max(0, maxNameLength - wallet.name.length)); choices.push({ name: ` ${envColor(envIcon)} ${paddedName} ${envLabel} ${colors.muted(wallet.address)}${activeLabel}`, value: wallet.name, short: wallet.name, }); lastEnv = wallet.environment; }); // Find the active wallet's name to set as default const activeWalletName = sortedWallets.find((w) => w.isActive)?.name; const { selectedWallet } = await safePrompt([ { type: 'list', name: 'selectedWallet', message: 'Select a wallet:', choices, pageSize: 50, default: activeWalletName, }, ]); const wallet = wallets.find((w) => w.name === selectedWallet); if (wallet) { // Only update if switching to a different wallet if (!wallet.isActive) { wallets.forEach((w) => { w.isActive = w.name === selectedWallet; }); await saveWallets(wallets); await setActiveWallet(wallet); animateSuccess(`Switched to wallet: ${wallet.name}`); setTimeout(() => { console.log(`${icons.dot} Environment: ${wallet.environment === 'production' ? colors.success(wallet.environment) : colors.warning(wallet.environment)}`); console.log(`${icons.dot} Address: ${colors.muted(wallet.address)}`); }, 1200); } else { console.log(`\n${icons.info} Already using wallet: ${colors.primary(wallet.name)}`); } } } catch (error) { console.error(`\n${icons.error} ${colors.error('Error listing wallets:')}`, error); } }); program .command('use <walletName>') .description('Switch to a different wallet') .action(async (walletName) => { try { const wallets = await getAllWallets(); if (wallets.length === 0) { console.error('❌ No wallets found. Run `mnee create` to create a wallet.'); return; } const wallet = wallets.find((w) => w.name.toLowerCase() === walletName.toLowerCase()); if (!wallet) { console.error(`❌ Wallet "${walletName}" not found.`); console.log('\nAvailable wallets:'); wallets.forEach((w) => { console.log(` - ${w.name} (${w.environment})`); }); return; } // Update all wallets to set the active state wallets.forEach((w) => { w.isActive = w.name === wallet.name; }); await saveWallets(wallets); await setActiveWallet(wallet); animateSuccess(`Switched to wallet: ${wallet.name}`); setTimeout(() => { console.log(`${icons.dot} Environment: ${wallet.environment === 'production' ? colors.success(wallet.environment) : colors.warning(wallet.environment)}`); console.log(`${icons.dot} Address: ${colors.muted(wallet.address)}`); }, 1200); } catch (error) { console.error(`\n${icons.error} ${colors.error('Error switching wallet:')}`, error); } }); program .command('rename <oldName> <newName>') .description('Rename a wallet') .action(async (oldName, newName) => { try { const validation = validateWalletName(newName); if (!validation.isValid) { console.error(`❌ ${validation.error}`); return; } const wallets = await getAllWallets(); if (wallets.length === 0) { console.error('❌ No wallets found. Run `mnee create` to create a wallet.'); return; } const wallet = wallets.find((w) => w.name.toLowerCase() === oldName.toLowerCase()); if (!wallet) { console.error(`❌ Wallet "${oldName}" not found.`); console.log('Run `mnee list` to see your available wallets.'); return; } if (wallets.some((w) => w.name.toLowerCase() === newName.toLowerCase() && w.name !== oldName)) { console.error(`❌ A wallet with name "${newName}" already exists (names are case-insensitive).`); return; } wallet.name = newName; await saveWallets(wallets); const activeWallet = await getActiveWallet(); if (activeWallet && activeWallet.name === oldName) { await setActiveWallet(wallet); } animateSuccess(`Wallet renamed from "${oldName}" to "${newName}"`); if (wallet.isActive) { setTimeout(() => { console.log(`${icons.star} ${colors.info('This is your active wallet.')}`); }, 1200); } } catch (error) { console.error(`\n${icons.error} ${colors.error('Error renaming wallet:')}`, error); } }); program .command('import') .description('Import an existing wallet using a WIF private key') .action(async () => { try { const existingWallets = await getAllWallets(); const { environment } = await safePrompt([ { type: 'list', name: 'environment', message: 'Select wallet environment:', choices: [ { name: 'Production', value: 'production' }, { name: 'Sandbox', value: 'sandbox' }, ], default: 'production', }, ]); const { wifKey } = await safePrompt([ { type: 'password', name: 'wifKey', message: 'Enter your WIF private key:', mask: '*', }, ]); let privateKey; try { privateKey = PrivateKey.fromWif(wifKey); } catch (error) { console.error('❌ Invalid WIF key. Please check and try again.'); return; } const address = privateKey.toAddress(); // Check if wallet with this address already exists const existingWallet = await getWalletByAddress(address); if (existingWallet) { console.error(`\n❌ A wallet with address ${address} already exists.`); console.log(`\nTo use this wallet, run: mnee use ${existingWallet.name}`); return; } const { walletName } = await safePrompt([ { type: 'input', name: 'walletName', message: `Enter a name for your ${environment} wallet:`, default: `${environment}-wallet-${Date.now()}`, validate: (input) => { const validation = validateWalletName(input); if (!validation.isValid) { return validation.error || 'Invalid wallet name'; } if (existingWallets.some((w) => w.name.toLowerCase() === input.toLowerCase())) { return `A wallet with name "${input}" already exists (names are case-insensitive)`; } return true; }, }, ]); const { password, confirmPassword } = await safePrompt([ { type: 'password', name: 'password', message: 'Set a password to encrypt your wallet:', mask: '*', validate: (input) => { if (input.length < 8) { return 'Password must be at least 8 characters long'; } // Check for at least one uppercase letter if (!/[A-Z]/.test(input)) { return 'Password must contain at least one uppercase letter'; } // Check for at least one lowercase letter if (!/[a-z]/.test(input)) { return 'Password must contain at least one lowercase letter'; } // Check for at least one number if (!/[0-9]/.test(input)) { return 'Password must contain at least one number'; } // Check for at least one special character if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(input)) { return 'Password must contain at least one special character'; } return true; }, }, { type: 'password', name: 'confirmPassword', message: 'Confirm your password:', mask: '*', }, ]); if (password !== confirmPassword) { console.error('❌ Passwords do not match. Try again.'); return; } const encryptedKey = encryptPrivateKey(privateKey.toString(), password); const newWallet = { address, environment, name: walletName, isActive: true, }; // Deactivate all other wallets existingWallets.forEach((wallet) => { wallet.isActive = false; }); existingWallets.push(newWallet); await saveWallets(existingWallets); await setPrivateKey(address, encryptedKey); await setActiveWallet(newWallet); animateSuccess('Wallet imported successfully!'); setTimeout(() => { showBox(`${icons.wallet} ${colors.highlight('Imported Wallet')}\n\n` + `${icons.dot} Name: ${colors.primary(walletName)}\n` + `${icons.dot} Environment: ${environment === 'production' ? colors.success(environment) : colors.warning(environment)}\n` + `${icons.dot} Address: ${colors.muted(address)}\n\n` + `${icons.check} ${colors.success('This wallet is now active')}`, 'Import Success', 'success'); }, 1200); } catch (error) { console.error(`\n${icons.error} ${colors.error('Error importing wallet:')}`, error); } }); program .command('login') .description('Authenticate with MNEE Developer Portal') .action(async () => { try { // Check if already logged in const config = await loadConfig(); if (config.token) { try { // Validate the token is still valid const profile = await getProfile(apiUrl, config.token); console.log(`\n✅ Already logged in as ${profile.email}`); console.log('\nTo log in as a different user, run `mnee logout` first.'); return; } catch (error) { // Token is invalid, continue with login flow console.log('⚠️ Previous session expired. Starting new authentication...\n'); } } console.log('🔐 Starting authentication flow...'); console.log('Press Ctrl+C to cancel at any time.\n'); const result = await startAuthFlow(apiUrl); // Update config with new auth info config.token = result.token; config.email = result.user.email; await saveConfig(config); animateSuccess(`Successfully authenticated as ${result.user.email}`); setTimeout(() => { showBox(`${icons.unlock} ${colors.highlight('Authentication Complete')}\n\n` + `${icons.dot} Logged in as: ${colors.primary(result.user.email)}\n\n` + `${colors.info('Available commands:')}\n` + ` ${colors.primary('mnee faucet')} - Request sandbox tokens\n` + ` ${colors.primary('mnee whoami')} - Show current user\n` + ` ${colors.primary('mnee logout')} - Sign out`, 'Welcome', 'success'); }, 1200); } catch (error) { console.error(`\n${icons.error} ${colors.error('Authentication failed:')}`, error.message); process.exit(1); } }); program .command('logout') .description('Sign out from MNEE Developer Portal') .action(async () => { try { const config = await loadConfig(); if (!config.token) { console.log('ℹ️ Not logged in.'); return; } // Call logout API await logoutApi(apiUrl, config.token); // Clear local config await clearConfig(); animateSuccess('Successfully logged out.'); } catch (error) { console.error(`${icons.error} ${colors.error('Error during logout:')}`, error.message); } }); program .command('whoami') .description('Show current authenticated user') .action(async () => { try { const config = await loadConfig(); if (!config.token) { console.log('❌ Not logged in. Run `mnee login` to authenticate.'); return; } try { const profile = await getProfile(apiUrl, config.token); showBox(`${icons.dot} Email: ${colors.primary(profile.email)}\n` + `${icons.dot} Name: ${colors.info(profile.name || 'Not set')}` + (profile.company ? `\n${icons.dot} Company: ${colors.info(profile.company)}` : ''), 'Current User', 'info'); } catch (error) { console.error('❌ Failed to get user profile. Your session may have expired.'); console.log('Run `mnee login` to authenticate again.'); } } catch (error) { console.error(`${icons.error} ${colors.error('Error:')}`, error.message); } }); program .command('faucet') .description('Request sandbox tokens (requires authentication)') .option('-a, --address <address>', 'Deposit address (defaults to active wallet)') .action(async (options)