ssdl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * YouTube Music search — finds the best matching video for a track
3
+ * Uses YouTube Music's public search to avoid needing an API key
4
+ */
5
+
6
+ const YT_MUSIC_URL = 'https://music.youtube.com';
7
+ const YT_SEARCH_URL = 'https://www.youtube.com/results';
8
+
9
+ /**
10
+ * Search for a track on YouTube and return the best match
11
+ */
12
+ export async function searchTrack(title, artist, durationMs) {
13
+ const query = `${title} ${artist}`;
14
+
15
+ try {
16
+ // Try YouTube search page scrape
17
+ const result = await searchYouTube(query, durationMs);
18
+ if (result) return result;
19
+ } catch {
20
+ // Fallback: try a simpler search
21
+ }
22
+
23
+ // Fallback: use a basic search approach
24
+ return searchYouTubeFallback(query, durationMs);
25
+ }
26
+
27
+ /**
28
+ * Search YouTube by scraping search results page
29
+ */
30
+ async function searchYouTube(query, durationMs) {
31
+ const encoded = encodeURIComponent(query);
32
+ const url = `${YT_SEARCH_URL}?search_query=${encoded}`;
33
+
34
+ const res = await fetch(url, {
35
+ headers: {
36
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
37
+ 'Accept-Language': 'en-US,en;q=0.9',
38
+ },
39
+ });
40
+
41
+ if (!res.ok) return null;
42
+
43
+ const html = await res.text();
44
+
45
+ // Extract ytInitialData JSON from the page
46
+ const dataMatch = html.match(/var ytInitialData = ({.*?});<\/script>/s);
47
+ if (!dataMatch) return null;
48
+
49
+ try {
50
+ const data = JSON.parse(dataMatch[1]);
51
+ const contents = data?.contents?.twoColumnSearchResultsRenderer?.primaryContents
52
+ ?.sectionListRenderer?.contents;
53
+
54
+ if (!contents) return null;
55
+
56
+ const videos = [];
57
+
58
+ for (const section of contents) {
59
+ const items = section?.itemSectionRenderer?.contents;
60
+ if (!items) continue;
61
+
62
+ for (const item of items) {
63
+ const video = item?.videoRenderer;
64
+ if (!video) continue;
65
+
66
+ const videoId = video.videoId;
67
+ const videoTitle = video?.title?.runs?.[0]?.text || '';
68
+ const videoDuration = parseDuration(video?.lengthText?.simpleText);
69
+
70
+ if (videoId && videoTitle) {
71
+ videos.push({
72
+ videoId,
73
+ title: videoTitle,
74
+ duration: videoDuration,
75
+ url: `https://www.youtube.com/watch?v=${videoId}`,
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ if (videos.length === 0) return null;
82
+
83
+ // Find best match by duration similarity
84
+ if (durationMs) {
85
+ const targetDuration = durationMs / 1000;
86
+ videos.sort((a, b) => {
87
+ const diffA = Math.abs(a.duration - targetDuration);
88
+ const diffB = Math.abs(b.duration - targetDuration);
89
+ return diffA - diffB;
90
+ });
91
+
92
+ // Only accept matches within 10 seconds of target duration
93
+ if (Math.abs(videos[0].duration - targetDuration) <= 10) {
94
+ return videos[0];
95
+ }
96
+ }
97
+
98
+ // If no duration match, return first result
99
+ return videos[0];
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Fallback: construct a YouTube Music search URL and extract video ID
107
+ * from the redirect
108
+ */
109
+ async function searchYouTubeFallback(query, durationMs) {
110
+ const encoded = encodeURIComponent(query + ' audio');
111
+ const url = `${YT_SEARCH_URL}?search_query=${encoded}`;
112
+
113
+ const res = await fetch(url, {
114
+ headers: {
115
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
116
+ 'Accept-Language': 'en-US,en;q=0.9',
117
+ },
118
+ });
119
+
120
+ if (!res.ok) return null;
121
+
122
+ const html = await res.text();
123
+
124
+ // Extract video IDs from the page with regex
125
+ const videoIds = [...html.matchAll(/\"videoId\":\"([a-zA-Z0-9_-]{11})\"/g)]
126
+ .map(m => m[1])
127
+ .filter((id, idx, arr) => arr.indexOf(id) === idx); // unique
128
+
129
+ if (videoIds.length === 0) {
130
+ throw new Error(`No YouTube results found for "${query}"`);
131
+ }
132
+
133
+ // Return first video
134
+ return {
135
+ videoId: videoIds[0],
136
+ title: query,
137
+ duration: durationMs ? durationMs / 1000 : 0,
138
+ url: `https://www.youtube.com/watch?v=${videoIds[0]}`,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Parse duration string "M:SS" or "H:MM:SS" to seconds
144
+ */
145
+ function parseDuration(str) {
146
+ if (!str) return 0;
147
+ const parts = str.split(':').map(Number);
148
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
149
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
150
+ return 0;
151
+ }
@@ -0,0 +1,247 @@
1
+ import readline from 'readline';
2
+
3
+ // ─── Key Input Handler ───────────────────────────────────
4
+ // Creates a raw-mode key listener that dispatches to callbacks
5
+ export function createInputHandler() {
6
+ const rl = readline.createInterface({
7
+ input: process.stdin,
8
+ output: process.stdout,
9
+ terminal: true,
10
+ });
11
+
12
+ // Enable raw mode for keypress detection
13
+ if (process.stdin.isTTY) {
14
+ process.stdin.setRawMode(true);
15
+ }
16
+ readline.emitKeypressEvents(process.stdin, rl);
17
+
18
+ const handlers = {};
19
+ let textBuffer = '';
20
+ let textMode = false;
21
+ let textCallback = null;
22
+ let textRenderCallback = null;
23
+
24
+ process.stdin.on('keypress', (str, key) => {
25
+ if (!key) return;
26
+
27
+ // Ctrl+C always exits
28
+ if (key.ctrl && key.name === 'c') {
29
+ cleanup();
30
+ process.exit(0);
31
+ }
32
+
33
+ // Text input mode
34
+ if (textMode) {
35
+ if (key.name === 'return') {
36
+ textMode = false;
37
+ if (textCallback) textCallback(textBuffer);
38
+ return;
39
+ }
40
+ if (key.name === 'escape') {
41
+ textMode = false;
42
+ textBuffer = '';
43
+ if (handlers['escape']) handlers['escape']();
44
+ return;
45
+ }
46
+ if (key.name === 'backspace') {
47
+ textBuffer = textBuffer.slice(0, -1);
48
+ if (textRenderCallback) textRenderCallback(textBuffer);
49
+ return;
50
+ }
51
+ if (str && !key.ctrl && !key.meta) {
52
+ textBuffer += str;
53
+ if (textRenderCallback) textRenderCallback(textBuffer);
54
+ return;
55
+ }
56
+ return;
57
+ }
58
+
59
+ // Navigation mode
60
+ const keyName = key.name;
61
+ if (handlers[keyName]) {
62
+ handlers[keyName](key);
63
+ }
64
+ if (key.name === 'space' && handlers['space']) {
65
+ handlers['space'](key);
66
+ }
67
+ if (str === 'a' && handlers['a']) handlers['a'](key);
68
+ if (str === 'A' && handlers['A']) handlers['A'](key);
69
+ if (str === 'q' && handlers['q']) handlers['q'](key);
70
+ if (str === 'Q' && handlers['Q']) handlers['Q'](key);
71
+ });
72
+
73
+ function cleanup() {
74
+ if (process.stdin.isTTY) {
75
+ process.stdin.setRawMode(false);
76
+ }
77
+ rl.close();
78
+ }
79
+
80
+ return {
81
+ // Register a key handler
82
+ on(keyName, callback) {
83
+ handlers[keyName] = callback;
84
+ return this;
85
+ },
86
+
87
+ // Remove a key handler
88
+ off(keyName) {
89
+ delete handlers[keyName];
90
+ return this;
91
+ },
92
+
93
+ // Clear all handlers
94
+ clearHandlers() {
95
+ for (const key of Object.keys(handlers)) {
96
+ delete handlers[key];
97
+ }
98
+ return this;
99
+ },
100
+
101
+ // Enter text input mode
102
+ startTextInput(initialValue = '', onChange, onSubmit) {
103
+ textBuffer = initialValue;
104
+ textMode = true;
105
+ textCallback = onSubmit;
106
+ textRenderCallback = onChange;
107
+ },
108
+
109
+ // Exit text input mode
110
+ stopTextInput() {
111
+ textMode = false;
112
+ textBuffer = '';
113
+ textCallback = null;
114
+ textRenderCallback = null;
115
+ },
116
+
117
+ // Get current text buffer
118
+ getTextBuffer() {
119
+ return textBuffer;
120
+ },
121
+
122
+ // Cleanup
123
+ destroy() {
124
+ cleanup();
125
+ },
126
+ };
127
+ }
128
+
129
+ // ─── Simple Menu Selector ────────────────────────────────
130
+ // Returns a promise that resolves with the selected index
131
+ export function menuSelect(items, input, renderFn) {
132
+ return new Promise((resolve) => {
133
+ let selectedIdx = 0;
134
+
135
+ function draw() {
136
+ renderFn(items, selectedIdx);
137
+ }
138
+
139
+ input.clearHandlers();
140
+
141
+ input.on('up', () => {
142
+ selectedIdx = (selectedIdx - 1 + items.length) % items.length;
143
+ draw();
144
+ });
145
+
146
+ input.on('down', () => {
147
+ selectedIdx = (selectedIdx + 1) % items.length;
148
+ draw();
149
+ });
150
+
151
+ input.on('return', () => {
152
+ input.clearHandlers();
153
+ resolve(selectedIdx);
154
+ });
155
+
156
+ input.on('escape', () => {
157
+ input.clearHandlers();
158
+ resolve(-1);
159
+ });
160
+
161
+ draw();
162
+ });
163
+ }
164
+
165
+ // ─── Checkbox Selector ───────────────────────────────────
166
+ // Returns a promise that resolves with a Set of selected indices
167
+ export function checkboxSelect(items, input, renderFn) {
168
+ return new Promise((resolve) => {
169
+ let selectedIdx = 0;
170
+ const checked = new Set();
171
+
172
+ // Start with all checked
173
+ items.forEach((_, i) => checked.add(i));
174
+
175
+ function draw() {
176
+ renderFn(items, selectedIdx, checked);
177
+ }
178
+
179
+ input.clearHandlers();
180
+
181
+ input.on('up', () => {
182
+ selectedIdx = (selectedIdx - 1 + items.length) % items.length;
183
+ draw();
184
+ });
185
+
186
+ input.on('down', () => {
187
+ selectedIdx = (selectedIdx + 1) % items.length;
188
+ draw();
189
+ });
190
+
191
+ input.on('space', () => {
192
+ if (checked.has(selectedIdx)) {
193
+ checked.delete(selectedIdx);
194
+ } else {
195
+ checked.add(selectedIdx);
196
+ }
197
+ draw();
198
+ });
199
+
200
+ input.on('a', () => {
201
+ if (checked.size === items.length) {
202
+ checked.clear();
203
+ } else {
204
+ items.forEach((_, i) => checked.add(i));
205
+ }
206
+ draw();
207
+ });
208
+
209
+ input.on('return', () => {
210
+ input.clearHandlers();
211
+ resolve(checked);
212
+ });
213
+
214
+ input.on('escape', () => {
215
+ input.clearHandlers();
216
+ resolve(null);
217
+ });
218
+
219
+ draw();
220
+ });
221
+ }
222
+
223
+ // ─── Text Input ──────────────────────────────────────────
224
+ // Returns a promise that resolves with the entered text
225
+ export function textInput(input, renderFn, initialValue = '') {
226
+ return new Promise((resolve) => {
227
+ input.clearHandlers();
228
+
229
+ input.startTextInput(
230
+ initialValue,
231
+ (text) => renderFn(text), // onChange
232
+ (text) => { // onSubmit
233
+ input.clearHandlers();
234
+ resolve(text);
235
+ }
236
+ );
237
+
238
+ input.on('escape', () => {
239
+ input.stopTextInput();
240
+ input.clearHandlers();
241
+ resolve(null);
242
+ });
243
+
244
+ // Initial render
245
+ renderFn(initialValue);
246
+ });
247
+ }
@@ -0,0 +1,208 @@
1
+ import { COLORS, BOX, SYMBOLS } from '../constants.js';
2
+
3
+ // ─── Cursor Control ──────────────────────────────────────
4
+ export const cursor = {
5
+ hide: () => process.stdout.write('\x1b[?25l'),
6
+ show: () => process.stdout.write('\x1b[?25h'),
7
+ moveTo: (row, col) => process.stdout.write(`\x1b[${row};${col}H`),
8
+ moveUp: (n = 1) => process.stdout.write(`\x1b[${n}A`),
9
+ moveDown: (n = 1) => process.stdout.write(`\x1b[${n}B`),
10
+ saveCursor: () => process.stdout.write('\x1b[s'),
11
+ restoreCursor: () => process.stdout.write('\x1b[u'),
12
+ };
13
+
14
+ // ─── Screen Control ──────────────────────────────────────
15
+ export const screen = {
16
+ clear: () => process.stdout.write('\x1b[2J\x1b[H'),
17
+ clearLine: () => process.stdout.write('\x1b[2K'),
18
+ clearDown: () => process.stdout.write('\x1b[J'),
19
+ getSize: () => ({
20
+ width: process.stdout.columns || 80,
21
+ height: process.stdout.rows || 24,
22
+ }),
23
+ };
24
+
25
+ // ─── Color Helpers ───────────────────────────────────────
26
+ export const c = {
27
+ reset: (str) => `${COLORS.reset}${str}${COLORS.reset}`,
28
+ bold: (str) => `${COLORS.bold}${str}${COLORS.reset}`,
29
+ dim: (str) => `${COLORS.dim}${str}${COLORS.reset}`,
30
+ italic: (str) => `${COLORS.italic}${str}${COLORS.reset}`,
31
+
32
+ red: (str) => `${COLORS.red}${str}${COLORS.reset}`,
33
+ green: (str) => `${COLORS.green}${str}${COLORS.reset}`,
34
+ yellow: (str) => `${COLORS.yellow}${str}${COLORS.reset}`,
35
+ blue: (str) => `${COLORS.blue}${str}${COLORS.reset}`,
36
+ magenta: (str) => `${COLORS.magenta}${str}${COLORS.reset}`,
37
+ cyan: (str) => `${COLORS.cyan}${str}${COLORS.reset}`,
38
+ white: (str) => `${COLORS.white}${str}${COLORS.reset}`,
39
+
40
+ brightGreen: (str) => `${COLORS.brightGreen}${str}${COLORS.reset}`,
41
+ brightCyan: (str) => `${COLORS.brightCyan}${str}${COLORS.reset}`,
42
+ brightYellow: (str) => `${COLORS.brightYellow}${str}${COLORS.reset}`,
43
+ brightMagenta: (str) => `${COLORS.brightMagenta}${str}${COLORS.reset}`,
44
+ brightBlue: (str) => `${COLORS.brightBlue}${str}${COLORS.reset}`,
45
+ brightWhite: (str) => `${COLORS.brightWhite}${str}${COLORS.reset}`,
46
+
47
+ // Gradient-ish effect using bright colors
48
+ gradient: (str) => `${COLORS.brightCyan}${str}${COLORS.reset}`,
49
+ };
50
+
51
+ // ─── Box Drawing ─────────────────────────────────────────
52
+ export function drawBox(text, options = {}) {
53
+ const { width = 45, padding = 1 } = options;
54
+ const innerWidth = width - 2;
55
+ const lines = [];
56
+
57
+ // Top border
58
+ lines.push(c.cyan(`${BOX.topLeft}${BOX.horizontal.repeat(innerWidth)}${BOX.topRight}`));
59
+
60
+ // Padding top
61
+ for (let i = 0; i < padding; i++) {
62
+ lines.push(c.cyan(`${BOX.vertical}${' '.repeat(innerWidth)}${BOX.vertical}`));
63
+ }
64
+
65
+ // Content lines
66
+ const textLines = Array.isArray(text) ? text : [text];
67
+ for (const line of textLines) {
68
+ const stripped = stripAnsi(line);
69
+ const padLen = Math.max(0, innerWidth - stripped.length);
70
+ const leftPad = Math.floor(padLen / 2);
71
+ const rightPad = padLen - leftPad;
72
+ lines.push(
73
+ c.cyan(BOX.vertical) +
74
+ ' '.repeat(leftPad) + line + ' '.repeat(rightPad) +
75
+ c.cyan(BOX.vertical)
76
+ );
77
+ }
78
+
79
+ // Padding bottom
80
+ for (let i = 0; i < padding; i++) {
81
+ lines.push(c.cyan(`${BOX.vertical}${' '.repeat(innerWidth)}${BOX.vertical}`));
82
+ }
83
+
84
+ // Bottom border
85
+ lines.push(c.cyan(`${BOX.bottomLeft}${BOX.horizontal.repeat(innerWidth)}${BOX.bottomRight}`));
86
+
87
+ return lines.join('\n');
88
+ }
89
+
90
+ // ─── Progress Bar ────────────────────────────────────────
91
+ export function progressBar(percent, width = 20) {
92
+ const filled = Math.round((percent / 100) * width);
93
+ const empty = width - filled;
94
+ const bar = c.brightGreen('█'.repeat(filled)) + c.dim('░'.repeat(empty));
95
+ const pct = `${Math.round(percent)}%`.padStart(4);
96
+ return `[${bar}] ${pct}`;
97
+ }
98
+
99
+ // ─── Table Drawing ───────────────────────────────────────
100
+ export function drawTable(headers, rows, options = {}) {
101
+ const { selected = -1, checked = new Set() } = options;
102
+
103
+ // Calculate column widths
104
+ const colWidths = headers.map((h, i) => {
105
+ const maxRow = rows.reduce((max, row) => Math.max(max, stripAnsi(String(row[i] || '')).length), 0);
106
+ return Math.max(stripAnsi(h).length, maxRow) + 2;
107
+ });
108
+
109
+ const totalWidth = colWidths.reduce((a, b) => a + b, 0) + headers.length + 1;
110
+ const lines = [];
111
+
112
+ // Top border
113
+ lines.push(
114
+ c.dim('┌') +
115
+ colWidths.map(w => c.dim('─'.repeat(w))).join(c.dim('┬')) +
116
+ c.dim('┐')
117
+ );
118
+
119
+ // Header
120
+ const headerLine = colWidths.map((w, i) => {
121
+ const text = ` ${headers[i]}`;
122
+ return c.bold(c.cyan(text.padEnd(w)));
123
+ }).join(c.dim('│'));
124
+ lines.push(c.dim('│') + headerLine + c.dim('│'));
125
+
126
+ // Header separator
127
+ lines.push(
128
+ c.dim('├') +
129
+ colWidths.map(w => c.dim('─'.repeat(w))).join(c.dim('┼')) +
130
+ c.dim('┤')
131
+ );
132
+
133
+ // Data rows
134
+ rows.forEach((row, rowIdx) => {
135
+ const isSelected = rowIdx === selected;
136
+ const isChecked = checked.has(rowIdx);
137
+
138
+ const rowLine = colWidths.map((w, i) => {
139
+ let text = ` ${String(row[i] || '')}`;
140
+ if (isSelected) {
141
+ text = c.brightCyan(text.padEnd(w));
142
+ } else if (isChecked) {
143
+ text = c.green(text.padEnd(w));
144
+ } else {
145
+ text = text.padEnd(w);
146
+ }
147
+ return text;
148
+ }).join(c.dim('│'));
149
+
150
+ const prefix = isSelected ? c.brightCyan('❯') : ' ';
151
+ const checkMark = isChecked ? c.green('✓') : ' ';
152
+
153
+ lines.push(c.dim('│') + rowLine + c.dim('│') + ` ${checkMark}`);
154
+ });
155
+
156
+ // Bottom border
157
+ lines.push(
158
+ c.dim('└') +
159
+ colWidths.map(w => c.dim('─'.repeat(w))).join(c.dim('┴')) +
160
+ c.dim('┘')
161
+ );
162
+
163
+ return lines.join('\n');
164
+ }
165
+
166
+ // ─── Render ──────────────────────────────────────────────
167
+ export function render(content) {
168
+ screen.clear();
169
+ process.stdout.write(content + '\n');
170
+ }
171
+
172
+ // ─── Spinner ─────────────────────────────────────────────
173
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
174
+
175
+ export function createSpinner(text) {
176
+ let frameIdx = 0;
177
+ let interval = null;
178
+ let line = 0;
179
+
180
+ return {
181
+ start(row = 0) {
182
+ line = row;
183
+ cursor.hide();
184
+ interval = setInterval(() => {
185
+ cursor.moveTo(line, 1);
186
+ screen.clearLine();
187
+ const frame = c.cyan(SPINNER_FRAMES[frameIdx]);
188
+ process.stdout.write(` ${frame} ${text}`);
189
+ frameIdx = (frameIdx + 1) % SPINNER_FRAMES.length;
190
+ }, 80);
191
+ },
192
+ stop(finalText) {
193
+ if (interval) clearInterval(interval);
194
+ cursor.moveTo(line, 1);
195
+ screen.clearLine();
196
+ if (finalText) {
197
+ process.stdout.write(` ${c.green('✓')} ${finalText}\n`);
198
+ }
199
+ cursor.show();
200
+ },
201
+ };
202
+ }
203
+
204
+ // ─── Utility: strip ANSI codes for width calculation ─────
205
+ export function stripAnsi(str) {
206
+ // eslint-disable-next-line no-control-regex
207
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
208
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { CONFIG_DIR, CONFIG_FILE, DEFAULT_DOWNLOAD_DIR } from '../constants.js';
3
+
4
+ const DEFAULT_CONFIG = {
5
+ spotifyClientId: '',
6
+ spotifyClientSecret: '',
7
+ downloadDir: DEFAULT_DOWNLOAD_DIR,
8
+ audioQuality: 'best',
9
+ audioFormat: 'mp3',
10
+ };
11
+
12
+ /**
13
+ * Load config from ~/.ssdl/config.json
14
+ * Creates default config if it doesn't exist
15
+ */
16
+ export function loadConfig() {
17
+ try {
18
+ if (!existsSync(CONFIG_DIR)) {
19
+ mkdirSync(CONFIG_DIR, { recursive: true });
20
+ }
21
+
22
+ if (!existsSync(CONFIG_FILE)) {
23
+ saveConfig(DEFAULT_CONFIG);
24
+ return { ...DEFAULT_CONFIG };
25
+ }
26
+
27
+ const raw = readFileSync(CONFIG_FILE, 'utf-8');
28
+ const config = JSON.parse(raw);
29
+ return { ...DEFAULT_CONFIG, ...config };
30
+ } catch {
31
+ return { ...DEFAULT_CONFIG };
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Save config to ~/.ssdl/config.json
37
+ */
38
+ export function saveConfig(config) {
39
+ try {
40
+ if (!existsSync(CONFIG_DIR)) {
41
+ mkdirSync(CONFIG_DIR, { recursive: true });
42
+ }
43
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
44
+ } catch (err) {
45
+ console.error('Failed to save config:', err.message);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if Spotify credentials are configured
51
+ */
52
+ export function hasCredentials(config) {
53
+ return !!(config.spotifyClientId && config.spotifyClientSecret);
54
+ }
55
+
56
+ /**
57
+ * Update specific config values
58
+ */
59
+ export function updateConfig(updates) {
60
+ const config = loadConfig();
61
+ const updated = { ...config, ...updates };
62
+ saveConfig(updated);
63
+ return updated;
64
+ }