Malachite is a tool to import your Last.fm and Spotify listening history to the AT Protocol network using the fm.teal.alpha.feed.play lexicon.
malachite scrobbles importer atproto music
at development 134 lines 3.7 kB view raw
1import * as readline from 'readline'; 2import chalk from 'chalk'; 3 4/** 5 * Strip surrounding quotes from a string (single or double quotes) 6 */ 7function stripQuotes(str: string): string { 8 str = str.trim(); 9 if ((str.startsWith("'") && str.endsWith("'")) || 10 (str.startsWith('"') && str.endsWith('"'))) { 11 return str.slice(1, -1); 12 } 13 return str; 14} 15 16/** 17 * Display a menu and get user selection 18 */ 19export async function menu(title: string, options: Array<{ key: string; label: string; description?: string }>): Promise<string> { 20 console.log(chalk.bold(`\n${title}`)); 21 console.log(chalk.gray('─'.repeat(50))); 22 23 for (const option of options) { 24 if (option.description) { 25 console.log(` ${chalk.cyan(option.key)}) ${option.label}`); 26 console.log(` ${chalk.gray(option.description)}`); 27 } else { 28 console.log(` ${chalk.cyan(option.key)}) ${option.label}`); 29 } 30 } 31 32 console.log(chalk.gray('─'.repeat(50))); 33 34 const validKeys = options.map(o => o.key.toLowerCase()); 35 let answer = ''; 36 37 while (!validKeys.includes(answer.toLowerCase())) { 38 answer = await prompt('Select an option: '); 39 if (!validKeys.includes(answer.toLowerCase())) { 40 console.log(chalk.red(`Invalid option. Please choose: ${validKeys.join(', ')}`)); 41 } 42 } 43 44 return answer.toLowerCase(); 45} 46 47/** 48 * Confirm an action with the user 49 */ 50export async function confirm(question: string, defaultYes = false): Promise<boolean> { 51 const suffix = defaultYes ? ' (Y/n) ' : ' (y/N) '; 52 const answer = await prompt(question + suffix); 53 54 if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { 55 return true; 56 } 57 if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') { 58 return false; 59 } 60 61 return defaultYes; 62} 63 64/** 65 * Read user input from command line with proper password masking 66 */ 67export function prompt(question: string, hideInput = false): Promise<string> { 68 return new Promise((resolve) => { 69 if (hideInput) { 70 // For password input, use raw mode 71 const stdin = process.stdin; 72 const wasRaw = stdin.isRaw; 73 74 // Set raw mode to capture individual keystrokes 75 if (stdin.isTTY) { 76 stdin.setRawMode(true); 77 } 78 79 stdin.resume(); 80 stdin.setEncoding('utf8'); 81 82 process.stdout.write(question); 83 84 let password = ''; 85 const onData = (char: Buffer | string) => { 86 const charStr = char.toString(); 87 88 switch (charStr) { 89 case '\n': 90 case '\r': 91 case '\u0004': // Ctrl-D 92 stdin.removeListener('data', onData); 93 if (stdin.isTTY) { 94 stdin.setRawMode(wasRaw); 95 } 96 stdin.pause(); 97 process.stdout.write('\n'); 98 resolve(password); 99 break; 100 case '\u0003': // Ctrl-C 101 process.exit(1); 102 break; 103 case '\u007f': // Backspace 104 case '\b': // Backspace 105 if (password.length > 0) { 106 password = password.slice(0, -1); 107 process.stdout.clearLine(0); 108 process.stdout.cursorTo(0); 109 process.stdout.write(question + '*'.repeat(password.length)); 110 } 111 break; 112 default: 113 password += charStr; 114 process.stdout.write('*'); 115 break; 116 } 117 }; 118 119 stdin.on('data', onData); 120 } else { 121 const rl = readline.createInterface({ 122 input: process.stdin, 123 output: process.stdout, 124 }); 125 126 rl.question(question, (answer) => { 127 rl.close(); 128 // Strip quotes from file paths 129 const cleaned = stripQuotes(answer); 130 resolve(cleaned); 131 }); 132 } 133 }); 134}