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
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}