From f270184956a429ade1a7112af2169049cbf464bd Mon Sep 17 00:00:00 2001 From: Seth Miller Date: Fri, 16 Oct 2026 11:16:49 -0400 Subject: [PATCH] Walk people through connecting Spotify inside sonic Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- bin/sonic.js | 271 ++++++++++++++++++++++++++++++++++++++++++++++--- install.sh | 2 +- lib/spotify.js | 117 +++++++++++++++++++-- lib/system.js | 19 ++-- lib/ui.js | 45 ++++++++ 6 files changed, 426 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index c2f54be..8f82a7f 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ sonic "deep house" # search and play sonic --soundcloud "liquid dnb" # search SoundCloud sonic --mixcloud "techno mix" # search Mixcloud sonic --spotify "daft punk" # search Spotify +sonic connect # connect Spotify, step by step sonic "https://youtu.be/..." # play a link (quote it) sonic doctor # check setup sonic --help # full help @@ -106,16 +107,15 @@ YouTube, SoundCloud, and Mixcloud play through `mpv` and need no account. Press Spotify is optional. Sonic acts as a remote: audio plays in a Spotify app (desktop, phone, or web player), and Sonic searches, starts, skips, seeks, and shows what is playing. ```bash -brew install steipete/tap/spogo # Spotify CLI that Sonic drives -# log in at https://open.spotify.com in Chrome, then: -spogo auth import --browser chrome # one-time; copies your Spotify session cookies -sonic --spotify "daft punk" +sonic connect # or press c on the home screen ``` +Connect walks you through three checked steps: install [spogo](https://github.com/steipete/spogo) (the Spotify CLI Sonic drives), bring over your Spotify login from your browser, and find a device to play on. macOS asks once to share your browser's saved logins; click **Always Allow**. + - **Vibes and sessions** open Spotify playlists. **Search** finds tracks. Spotify links (`open.spotify.com/...` or `spotify:` URIs) play directly. - **How auth works:** [spogo](https://github.com/steipete/spogo) uses your own Spotify web session cookies, stored locally. Sonic never sees or stores your password, and no developer app is needed. - **Premium:** Spotify only allows remote playback control for Premium accounts. On macOS with the Spotify desktop app open, spogo can drive the app directly instead. -- Spotify shows up in the `m` cycle once `spogo` is installed. `sonic doctor` reports whether you are logged in. +- Press `m` to switch to Spotify any time. If it is not connected yet, Sonic opens Connect. `sonic doctor` reports whether you are logged in. - Unofficial: this uses Spotify's web player endpoints the same way your browser does. Use it with your own account. ## Requirements diff --git a/bin/sonic.js b/bin/sonic.js index c7ec199..8b7525b 100755 --- a/bin/sonic.js +++ b/bin/sonic.js @@ -2,10 +2,21 @@ import readline from 'node:readline'; import { readFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; import { search, SOURCES, parseSourceUrl, resolveUrl } from '../lib/search.js'; import { Player } from '../lib/player.js'; -import { SpotifyPlayer, hasSpogo } from '../lib/spotify.js'; +import { + SpotifyPlayer, + SpotifySetupError, + detectBrowsers, + hasHomebrew, + hasSpogo, + importLogin, + isSpotifyConnected, + listDevices, + verifyLogin +} from '../lib/spotify.js'; import { UI } from '../lib/ui.js'; import { CHANNELS, ACTIVITIES, @@ -36,6 +47,7 @@ const packageJson = JSON.parse( const version = packageJson.version; const args = process.argv.slice(2); const doctorMode = args.includes('--doctor') || (args.length === 1 && args[0] === 'doctor'); +const connectMode = args.length === 1 && args[0] === 'connect'; if (args.includes('--help') || args.includes('-h')) { printHelp(version); @@ -64,9 +76,7 @@ let currentTrack = null; let currentQuery = ''; let currentSearchOptions = {}; let state = 'idle'; // idle | picker | searching | results | playing | search_input -let source = availableSources().includes(store.config.defaultSource) - ? store.config.defaultSource - : 'youtube'; +let source = store.config.defaultSource; let queue = []; let history = []; let resultsMeta = { @@ -90,9 +100,8 @@ if (args.includes('--sp') || args.includes('--spotify')) { source = 'spotify'; } -// Spotify joins the source cycle only once spogo is installed. -function availableSources() { - return SOURCES.filter((name) => name !== 'spotify' || hasSpogo()); +function spotifyReady() { + return hasSpogo() && isSpotifyConnected(); } function isSpotifyTrack() { @@ -112,7 +121,7 @@ let resultsCursor = 0; const queryArgs = args .filter((arg) => !arg.startsWith('--')) - .filter((arg) => arg !== 'doctor') + .filter((arg) => arg !== 'doctor' && !(connectMode && arg === 'connect')) .join(' ') .trim(); @@ -185,9 +194,8 @@ function setSequenceFromList(list, index) { } function cycleSource() { - const sources = availableSources(); - const idx = sources.indexOf(source); - source = sources[(idx + 1) % sources.length]; + const idx = SOURCES.indexOf(source); + source = SOURCES[(idx + 1) % SOURCES.length]; persistConfig({ defaultSource: source }); } @@ -295,10 +303,13 @@ function renderPicker() { process.stdout.write(ui.header(source)); const cursor = pickerTab === 'vibes' ? vibesCursor : activityCursor; const items = pickerTab === 'vibes' ? CHANNELS : ACTIVITIES; + const needsConnect = source === 'spotify' && !spotifyReady(); process.stdout.write(ui.picker(items, cursor, pickerTab, { source, favoritesCount: store.favorites.length, - recentsCount: store.recents.length + recentsCount: store.recents.length, + sourceNote: needsConnect ? 'not connected yet · press c to connect' : null, + needsConnect })); } @@ -379,6 +390,10 @@ async function runWithLoader(tips, task) { } function showLoadFailure(label, err) { + if (err instanceof SpotifySetupError) { + showConnect({ error: err.message }); + return; + } promptSearch(ui.notice(label, err.message)); } @@ -391,6 +406,14 @@ async function doSearch(query, { preset = false, label = null } = {}) { currentQuery = query; currentSearchOptions = { preset, label }; + + // Without a saved login, spogo falls back to reading the browser, which can + // raise a surprise Keychain prompt. Go to Connect instead. + if (source === 'spotify' && !spotifyReady()) { + showConnect({ error: 'connect spotify first, then pick again.' }); + return; + } + state = 'searching'; const outcome = await runWithLoader([ @@ -462,6 +485,10 @@ async function playTrack(track) { enableRawKeys(); const remote = hydrated.source === 'spotify'; + if (remote && !spotifyReady()) { + showConnect({ error: 'connect spotify first, then pick again.' }); + return; + } let frame = 0; let loadingStage = remote ? 'remote' : 'connecting'; const loadingAnim = setInterval(() => { @@ -505,10 +532,214 @@ async function playTrack(track) { } catch (err) { clearInterval(loadingAnim); if (state !== 'playing') return; + if (err instanceof SpotifySetupError) { + showConnect({ error: err.message }); + return; + } promptSearch(ui.notice(remote ? 'spotify:' : 'playback failed:', err.message)); } } +// ─── Spotify connect flow ─────────────────────────────────────────────────── +// Walks through install → login → device, checking each step for real. + +let connect = null; + +function showConnect({ error = null } = {}) { + ui.stopVisualizer(); + state = 'connect'; + connect = { + browsers: detectBrowsers(), + browserIdx: 0, + busy: null, + frame: 0, + timer: null, + message: null, + error, + verified: false, + devices: null + }; + ui.hideCursor(); + enableRawKeys(); + ui.clear(); + if (spotifyReady()) void runConnectTask('verify'); + renderConnect(); +} + +function connectBrowser() { + return connect.browsers[connect.browserIdx]; +} + +function connectStepIndex() { + if (!hasSpogo()) return 0; + if (!isSpotifyConnected() || !connect.verified) return 1; + if (!connect.devices?.length) return 2; + return 3; +} + +function renderConnect() { + if (state !== 'connect') return; + const browser = connectBrowser(); + const current = connectStepIndex(); + const busyStep = { install: 0, import: 1, verify: 1, devices: 2 }[connect.busy]; + const stepState = (i) => (busyStep === i ? 'busy' : i < current ? 'done' : i === current ? 'current' : 'todo'); + const deviceNames = connect.devices?.map((d) => d.name).join(', '); + + const steps = [ + { + label: 'install spogo, the spotify helper sonic drives', + detail: hasHomebrew() + ? 'press enter to install it with homebrew.' + : 'install go, then run: go install github.com/steipete/spogo/cmd/spogo@latest', + doneDetail: 'installed', + state: stepState(0) + }, + { + label: `connect your spotify login from ${browser.label}`, + detail: connect.busy === 'import' + ? `reading ${browser.label}... if macOS asks about saved logins, click "Always Allow".` + : connect.busy === 'verify' + ? 'checking that search works...' + : `log in at open.spotify.com in ${browser.label} first (press o).\nthen press enter. macOS asks to share saved logins: click "Always Allow".`, + doneDetail: 'connected, search works', + state: stepState(1) + }, + { + label: 'open spotify where you want to hear it', + detail: deviceNames + ? `found: ${deviceNames}` + : 'the desktop app, your phone, or the web player (press o). then press enter.', + doneDetail: deviceNames ? `found: ${deviceNames}` : null, + state: stepState(2) + } + ]; + + const done = current === 3; + const keys = done + ? [['enter', 'start listening'], ['esc', 'back'], ['q', 'quit']] + : [ + ['enter', ['install', 'connect', 'check devices'][current]], + current >= 1 ? ['o', current === 1 ? `open spotify in ${browser.label}` : 'open web player'] : null, + connect.browsers.length > 1 && current === 1 ? ['b', 'other browser'] : null, + current === 2 ? ['s', 'skip for now'] : null, + ['esc', 'back'], + ['q', 'quit'] + ]; + + const screen = ui.header('spotify') + ui.connectScreen({ + steps, + browser, + browsers: current === 1 ? connect.browsers : [], + busyFrame: connect.frame, + message: done ? 'spotify is connected.' : connect.message, + error: connect.error, + keys + }); + process.stdout.write('\x1b[H' + screen.replace(/\n/g, '\x1b[K\n') + '\x1b[J'); +} + +async function runConnectTask(task) { + if (connect.busy) return; + connect.busy = task; + connect.error = null; + connect.message = null; + connect.timer = setInterval(() => { connect.frame++; renderConnect(); }, 80); + try { + if (task === 'import') { + await importLogin(connectBrowser()); + task = 'verify'; + connect.busy = 'verify'; + } + if (task === 'verify') { + await verifyLogin(); + connect.verified = true; + task = 'devices'; + connect.busy = 'devices'; + } + if (task === 'devices') { + connect.devices = await listDevices(); + if (!connect.devices.length) { + connect.error = 'no spotify device is open yet. open spotify somewhere, then press enter.'; + } + } + } catch (err) { + connect.error = err.message; + } finally { + clearInterval(connect.timer); + connect.busy = null; + renderConnect(); + } +} + +// Homebrew prints its own progress, so hand it the terminal for the install. +function installSpogo() { + if (!hasHomebrew()) return; + disableRawKeys(); + ui.showCursor(); + ui.clear(); + process.stdout.write('installing spogo with homebrew...\n\n'); + const result = spawnSync('brew', ['install', 'steipete/tap/spogo'], { stdio: 'inherit' }); + ui.hideCursor(); + enableRawKeys(); + ui.clear(); + if (!hasSpogo({ recheck: true })) { + connect.error = result.status === 0 + ? 'spogo installed, but this terminal cannot find it yet. open a new terminal and run: sonic connect' + : `homebrew could not install spogo (exit ${result.status}). run: brew install steipete/tap/spogo`; + } + renderConnect(); +} + +function openSpotifyWeb() { + const browser = connectBrowser(); + const url = 'https://open.spotify.com'; + if (process.platform === 'darwin') { + spawn('open', ['-a', browser.app, url], { stdio: 'ignore', detached: true }).unref(); + return; + } + void openExternal(url).catch(() => {}); +} + +function finishConnect() { + source = 'spotify'; + persistConfig({ defaultSource: source }); + showPicker(); +} + +async function handleConnectKey(key) { + if (key === 'q') { + cleanup(0); + return; + } + if (key === '\x1b') { + showPicker(); + return; + } + if (connect.busy) return; + + const current = connectStepIndex(); + if (key === '\r' || key === '\n') { + if (current === 0) installSpogo(); + else if (current === 1) await runConnectTask('import'); + else if (current === 2) await runConnectTask('devices'); + else finishConnect(); + return; + } + if (key === 'o' && current >= 1) { + openSpotifyWeb(); + return; + } + if (key === 'b' && current === 1 && connect.browsers.length > 1) { + connect.browserIdx = (connect.browserIdx + 1) % connect.browsers.length; + connect.error = null; + renderConnect(); + return; + } + if (key === 's' && current === 2) { + finishConnect(); + } +} + async function playSelectedResult(index) { if (!results.length) return; setSequenceFromList(results, index); @@ -586,6 +817,11 @@ process.stdin.on('data', async (key) => { return; } + if (state === 'connect') { + await handleConnectKey(key); + return; + } + if (state === 'picker') { if (key === '\t' || isLeft(key) || isRight(key)) { pickerTab = pickerTab === 'vibes' ? 'activities' : 'vibes'; @@ -641,6 +877,11 @@ process.stdin.on('data', async (key) => { return; } + if (key === 'c') { + showConnect(); + return; + } + if (key === 'm') { cycleSource(); renderPicker(); @@ -871,6 +1112,7 @@ process.stdout.on('resize', () => { if (state === 'picker') renderPicker(); else if (state === 'results') renderResultsList(); else if (state === 'playing') renderPlaying(); + else if (state === 'connect') renderConnect(); }); player.on('ended', async () => { @@ -904,6 +1146,11 @@ async function main() { ui.hideCursor(); + if (connectMode) { + showConnect(); + return; + } + if (queryArgs) { const channel = resolveChannel(queryArgs); enableRawKeys(); diff --git a/install.sh b/install.sh index 8c99e25..e8bab2d 100755 --- a/install.sh +++ b/install.sh @@ -117,5 +117,5 @@ echo -e " Run: ${BOLD}sonic${RESET} — open the picker" echo -e " Run: ${BOLD}sonic \"lofi\"${RESET} — search and play" echo -e " Run: ${BOLD}sonic doctor${RESET} — verify setup" echo "" -echo -e " ${DIM}Spotify (optional): brew install steipete/tap/spogo && spogo auth import --browser chrome${RESET}" +echo -e " ${DIM}Spotify (optional): run sonic connect${RESET}" echo "" diff --git a/lib/spotify.js b/lib/spotify.js index d9dcd98..76ef47b 100644 --- a/lib/spotify.js +++ b/lib/spotify.js @@ -1,6 +1,9 @@ import { execFile, spawnSync } from 'node:child_process'; import { promisify } from 'node:util'; import { EventEmitter } from 'node:events'; +import { existsSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; const exec = promisify(execFile); @@ -8,20 +11,14 @@ const exec = promisify(execFile); // remote control here: audio plays in a Spotify app (desktop, phone, or web // player), and sonic searches, starts, and steers it. -export const SPOTIFY_SETUP = [ - 'spotify needs spogo and a one-time login:', - ' brew install steipete/tap/spogo', - ' log in at https://open.spotify.com in chrome', - ' spogo auth import --browser chrome', - 'keep a spotify app open (desktop, phone, or web player) so there is a device to play on.' -].join('\n'); +export const SPOTIFY_SETUP = 'connect spotify first, then pick again.'; export class SpotifySetupError extends Error {} let spogoChecked = null; -export function hasSpogo() { - if (spogoChecked === null) { +export function hasSpogo({ recheck = false } = {}) { + if (spogoChecked === null || recheck) { const result = spawnSync('spogo', ['--version'], { encoding: 'utf8' }); spogoChecked = !result.error && result.status === 0; } @@ -48,7 +45,7 @@ function toSpotifyError(err) { const raw = String(err?.stderr || err?.message || 'spogo failed').trim(); const line = raw.split('\n').map(l => l.trim()).filter(Boolean).pop() || raw; if (/cookie|unauthori[sz]ed|401|not logged|auth/i.test(raw)) { - return new SpotifySetupError(`spotify is not connected (${line}).\n${SPOTIFY_SETUP}`); + return new SpotifySetupError('connect spotify first, then pick again.'); } if (/no active device|no device|device not found|404/i.test(raw)) { return new Error('no spotify device found. open spotify on this mac, your phone, or open.spotify.com, then try again.'); @@ -221,3 +218,103 @@ export class SpotifyPlayer extends EventEmitter { } } } + +// ─── Connect flow helpers ─────────────────────────────────────────────────── + +// spogo keeps imported cookies under its config dir. Checking the folder +// avoids `spogo auth status`, which falls back to reading the browser and +// can raise a Keychain prompt. +function spogoConfigDir() { + if (process.env.SPOGO_CONFIG) return dirname(process.env.SPOGO_CONFIG); + if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'spogo'); + return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'spogo'); +} + +export function isSpotifyConnected() { + const dir = join(spogoConfigDir(), 'cookies'); + try { + return existsSync(dir) && readdirSync(dir).some((name) => !name.startsWith('.')); + } catch { + return false; + } +} + +const BROWSERS = [ + { id: 'chrome', label: 'Chrome', app: 'Google Chrome', bundle: 'com.google.chrome' }, + { id: 'arc', label: 'Arc', app: 'Arc', bundle: 'company.thebrowser.browser' }, + { id: 'dia', label: 'Dia', app: 'Dia', bundle: 'company.thebrowser.dia' }, + { id: 'brave', label: 'Brave', app: 'Brave Browser', bundle: 'com.brave.browser' }, + { id: 'edge', label: 'Edge', app: 'Microsoft Edge', bundle: 'com.microsoft.edgemac' }, + { id: 'vivaldi', label: 'Vivaldi', app: 'Vivaldi', bundle: 'com.vivaldi.vivaldi' }, + { id: 'firefox', label: 'Firefox', app: 'Firefox', bundle: 'org.mozilla.firefox' }, + { id: 'zen', label: 'Zen', app: 'Zen', bundle: 'app.zen-browser.zen' }, + { id: 'safari', label: 'Safari', app: 'Safari', bundle: 'com.apple.safari' } +]; + +function defaultBrowserBundle() { + const plist = join(homedir(), 'Library', 'Preferences', 'com.apple.LaunchServices', 'com.apple.launchservices.secure.plist'); + const result = spawnSync('plutil', ['-convert', 'json', '-o', '-', plist], { encoding: 'utf8' }); + if (result.status !== 0) return 'com.apple.safari'; + try { + const handlers = JSON.parse(result.stdout).LSHandlers || []; + const https = handlers.find((h) => h.LSHandlerURLScheme === 'https'); + return (https?.LSHandlerRoleAll || 'com.apple.safari').toLowerCase(); + } catch { + return 'com.apple.safari'; + } +} + +// Installed browsers, the default one first. Safari goes last when anything +// else is installed: reading its cookies needs Full Disk Access for the terminal. +export function detectBrowsers() { + if (process.platform !== 'darwin') { + return BROWSERS.filter((b) => !['safari', 'arc', 'dia'].includes(b.id)); + } + const installed = BROWSERS.filter((b) => + existsSync(join('/Applications', `${b.app}.app`)) || existsSync(join(homedir(), 'Applications', `${b.app}.app`)) + ); + const bundle = defaultBrowserBundle(); + installed.sort((a, b) => { + const rank = (x) => (x.id === 'safari' && installed.length > 1 ? 2 : x.bundle === bundle ? 0 : 1); + return rank(a) - rank(b); + }); + return installed.length ? installed : BROWSERS.filter((b) => b.id === 'chrome'); +} + +export function hasHomebrew() { + const result = spawnSync('brew', ['--version'], { encoding: 'utf8' }); + return !result.error && result.status === 0; +} + +// Reads the Spotify login from the browser. macOS asks for Keychain access +// the first time; the call waits while the person answers that prompt. +export async function importLogin(browser) { + try { + await exec('spogo', ['auth', 'import', '--browser', browser.id, '--no-input', '--no-color'], { timeout: 120000 }); + } catch (err) { + const raw = String(err?.stderr || err?.stdout || err?.message || '').trim(); + const line = raw.split('\n').map((l) => l.trim()).filter(Boolean).pop() || 'import failed'; + if (/no cookies/i.test(raw)) { + throw new Error(`no spotify login found in ${browser.label}. log in at open.spotify.com there first (press o).`); + } + if (/keychain|safe storage|signal: killed/i.test(raw)) { + throw new Error(`macOS did not allow access to ${browser.label}'s saved logins. press enter and choose "Always Allow".`); + } + if (/full disk|operation not permitted/i.test(raw)) { + throw new Error('reading Safari needs Full Disk Access for your terminal (System Settings → Privacy & Security), or press b to pick another browser.'); + } + throw new Error(line); + } + if (!isSpotifyConnected()) throw new Error(`no spotify login found in ${browser.label}. log in at open.spotify.com there first (press o).`); +} + +// A real search proves the login works end to end. +export async function verifyLogin() { + await searchSpotify('daft punk', { limit: 1 }); +} + +export async function listDevices() { + const data = await spogoJson(['device', 'list'], { timeout: 10000 }); + const devices = Array.isArray(data) ? data : (data?.devices || []); + return devices.map((d) => ({ name: d.name, type: d.type, active: Boolean(d.is_active) })); +} diff --git a/lib/system.js b/lib/system.js index b1521f2..54963b7 100644 --- a/lib/system.js +++ b/lib/system.js @@ -1,4 +1,5 @@ import { spawn, spawnSync } from 'node:child_process'; +import { isSpotifyConnected } from './spotify.js'; const REQUIRED_DEPENDENCIES = [ { @@ -79,15 +80,12 @@ export function buildDoctorReport({ version, store, storePaths }) { lines.push('Spotify (optional):'); const spogo = inspectBinary('spogo'); if (!spogo.found) { - lines.push(' - spogo: not installed (brew install steipete/tap/spogo)'); + lines.push(' - spogo: not installed. run: sonic connect'); } else { lines.push(` - spogo: OK (${spogo.version})`); - const auth = spawnSync('spogo', ['auth', 'status', '--no-input', '--no-color'], { encoding: 'utf8', timeout: 10000 }); - const authLine = trimFirstLine(auth.stdout || auth.stderr) || 'unknown'; - const connected = auth.status === 0 && !/no cookies|missing|not found/i.test(authLine); - lines.push(connected - ? ` - login: ${authLine}` - : ' - login: not connected. log in at open.spotify.com in chrome, then run: spogo auth import --browser chrome'); + lines.push(isSpotifyConnected() + ? ' - login: connected' + : ' - login: not connected. run: sonic connect'); } lines.push(''); @@ -120,6 +118,7 @@ Usage: sonic --soundcloud sonic --mixcloud sonic --spotify + sonic connect Connect Spotify, step by step sonic doctor Flags: @@ -142,9 +141,8 @@ Requirements: - mpv Spotify (optional): - - brew install steipete/tap/spogo - - log in at open.spotify.com in Chrome, then: spogo auth import --browser chrome - - keep a Spotify app open (desktop, phone, or web player); audio plays there + - run sonic connect (or press c on the home screen) and follow the steps + - audio plays in a Spotify app (desktop, phone, or web player); sonic is the remote - vibes and sessions open Spotify playlists; search finds tracks Picker Controls: @@ -154,6 +152,7 @@ Picker Controls: - /: search - f: favorites - r: recents + - c: connect Spotify - m: toggle source - t / T: next / previous theme - d: toggle dark / light mode diff --git a/lib/ui.js b/lib/ui.js index 368d236..c44ef8a 100644 --- a/lib/ui.js +++ b/lib/ui.js @@ -313,6 +313,7 @@ export class UI { ['/', 'search'], ['f', 'favorites'], ['r', 'recents'], + options.needsConnect ? ['c', 'connect spotify'] : null, ['m', 'source'], ['t', 'theme'], ['d', 'light/dark'], @@ -321,6 +322,50 @@ export class UI { return out; } + // ─── Spotify connect ───────────────────────────────────────────────────── + + // steps: [{ label, detail, state: 'done' | 'current' | 'todo' | 'busy' }] + connectScreen({ steps, browser, browsers, busyFrame = 0, message, error, keys }) { + const theme = this.theme; + const green = PLATFORM_BRANDS.spotify.color; + const spin = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let out = `\n ${chalk.hex(green).bold('♫ Connect Spotify')} ${chalk.hex(theme.muted)('one time, about a minute')}\n\n`; + + steps.forEach((step, i) => { + const mark = step.state === 'done' + ? chalk.hex(green).bold('✓') + : step.state === 'busy' + ? chalk.hex(theme.spinner)(spin[busyFrame % spin.length]) + : step.state === 'current' + ? chalk.hex(theme.accent).bold('▸') + : chalk.hex(theme.muted)('○'); + const label = step.state === 'todo' + ? chalk.hex(theme.muted)(step.label) + : chalk.hex(theme.text)[step.state === 'current' || step.state === 'busy' ? 'bold' : 'reset'](step.label); + out += ` ${mark} ${chalk.hex(theme.muted)(String(i + 1))} ${label}\n`; + const detail = step.state === 'done' ? step.doneDetail : step.state === 'todo' ? null : step.detail; + if (detail) { + for (const line of detail.split('\n')) out += ` ${chalk.hex(theme.muted)(line)}\n`; + } + }); + + if (browsers.length > 1) { + const list = browsers + .map((b) => (b.id === browser.id ? chalk.hex(theme.text).bold(b.label) : chalk.hex(theme.muted)(b.label))) + .join(chalk.hex(theme.muted)(' · ')); + out += `\n ${chalk.hex(theme.muted)('browser')} ${list}\n`; + } + if (message) out += `\n ${chalk.hex(green)(message)}\n`; + if (error) { + const [first, ...rest] = error.split('\n'); + out += `\n ${chalk.hex(theme.favorite).bold('!')} ${chalk.hex(theme.text)(first)}\n`; + for (const line of rest) out += ` ${chalk.hex(theme.muted)(line)}\n`; + } + out += '\n' + this.hints(keys) + '\n'; + out += `\n ${chalk.hex(theme.muted)('sonic never sees your password. spogo keeps your spotify login on this machine.')}\n`; + return out; + } + // ─── Search / Results UI ───────────────────────────────────────────────── searchPrompt(source) {