import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { createServer } from 'http'; import { homedir } from 'os'; import { join } from 'path'; import { exec } from 'child_process'; import { assertSpotifyCredentialsConfigured, getFirstSpotifyTrack, mapSpotifyTrackResults, parseDotEnv, resolveSpotifyCredentials, } from './utils.js'; // ── Credentials ─────────────────────────────────────────────────────────────── // Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET as environment variables, // or place them in ~/.opencli/spotify.env: // SPOTIFY_CLIENT_ID=your_id // SPOTIFY_CLIENT_SECRET=your_secret const ENV_FILE = join(homedir(), '.opencli', 'spotify.env'); function loadEnv() { if (!existsSync(ENV_FILE)) return {}; return parseDotEnv(readFileSync(ENV_FILE, 'utf-8')); } const env = loadEnv(); const credentials = resolveSpotifyCredentials(env); const CLIENT_ID = credentials.clientId; const CLIENT_SECRET = credentials.clientSecret; const REDIRECT_URI = 'http://127.0.0.1:8888/callback'; const SCOPES = [ 'user-read-playback-state', 'user-modify-playback-state', 'user-read-currently-playing', 'playlist-read-private', ].join(' '); // ── Token storage ───────────────────────────────────────────────────────────── const TOKEN_FILE = join(homedir(), '.opencli', 'spotify-tokens.json'); function loadTokens() { try { return JSON.parse(readFileSync(TOKEN_FILE, 'utf-8')); } catch { return null; } } function saveTokens(tokens) { mkdirSync(join(homedir(), '.opencli'), { recursive: true }); writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2)); } async function refreshAccessToken(refreshToken) { const res = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new CliError('REFRESH_FAILED', err?.error_description || `Token refresh failed (${res.status})`); } const data = await res.json(); const tokens = { access_token: data.access_token, refresh_token: data.refresh_token || refreshToken, expires_at: Date.now() + data.expires_in * 1000, }; saveTokens(tokens); return tokens.access_token; } async function getToken() { const tokens = loadTokens(); if (!tokens) throw new CliError('AUTH_REQUIRED', 'Not authenticated. Run: opencli spotify auth'); if (!tokens.access_token || !tokens.refresh_token || !(tokens.expires_at > 0)) { throw new CliError('AUTH_CORRUPTED', 'Token file is corrupted. Run: opencli spotify auth'); } if (Date.now() > tokens.expires_at - 60_000) return refreshAccessToken(tokens.refresh_token); return tokens.access_token; } // ── Spotify API helper ──────────────────────────────────────────────────────── async function api(method, path, body) { const token = await getToken(); const res = await fetch(`https://api.spotify.com/v1${path}`, { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); if (res.status === 204 || res.status === 202) return null; if (!res.ok) { const err = await res.json().catch(() => ({})); throw new CliError('API_ERROR', err?.error?.message || `Spotify API error ${res.status}`); } return res.json(); } async function findTrackUri(query) { const data = await api('GET', `/search?q=${encodeURIComponent(query)}&type=track&limit=1`); const track = getFirstSpotifyTrack(data); if (!track) throw new CliError('EMPTY_RESULT', `No track found for: ${query}`); return track; } function openBrowser(url) { const cmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`; exec(cmd); } // ── Commands ────────────────────────────────────────────────────────────────── cli({ site: 'spotify', name: 'auth', access: 'write', description: 'Authenticate with Spotify (OAuth — run once)', strategy: Strategy.PUBLIC, browser: false, args: [], columns: ['status'], func: async () => { assertSpotifyCredentialsConfigured(credentials, ENV_FILE); return new Promise((resolve, reject) => { const server = createServer(async (req, res) => { try { const url = new URL(req.url, 'http://localhost:8888'); if (url.pathname !== '/callback') { res.end(); return; } const code = url.searchParams.get('code'); if (!code) { res.end('Missing code'); return; } const tokenRes = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI }), }); if (!tokenRes.ok) { const err = await tokenRes.json().catch(() => ({})); server.close(); reject(new CliError('AUTH_FAILED', err?.error_description || `Token exchange failed (${tokenRes.status})`)); return; } const data = await tokenRes.json(); saveTokens({ access_token: data.access_token, refresh_token: data.refresh_token, expires_at: Date.now() + data.expires_in * 1000 }); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Spotify authenticated! You can close this tab.

'); server.close(); resolve([{ status: 'Authenticated successfully' }]); } catch (e) { server.close(); reject(e); } }); server.on('error', (e) => { if (e.code === 'EADDRINUSE') reject(new CliError('PORT_IN_USE', 'Port 8888 is already in use. Stop the other process and retry.')); else reject(e); }); const timeout = setTimeout(() => { server.close(); reject(new CliError('AUTH_TIMEOUT', 'Authentication timed out after 5 minutes')); }, 5 * 60 * 1000); server.listen(8888, () => { const authUrl = `https://accounts.spotify.com/authorize?${new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: REDIRECT_URI, scope: SCOPES })}`; console.log('Opening browser for Spotify login...'); console.log('If it does not open, visit:', authUrl); openBrowser(authUrl); }); server.on('close', () => clearTimeout(timeout)); }); }, }); cli({ site: 'spotify', name: 'status', access: 'read', description: 'Show current playback status', strategy: Strategy.PUBLIC, browser: false, args: [], columns: ['track', 'artist', 'album', 'status', 'progress'], func: async () => { const data = await api('GET', '/me/player'); if (!data || !data.item) return [{ track: 'Nothing playing', artist: '', album: '', status: '', progress: '' }]; const t = data.item; if (t.type !== 'track') return [{ track: t.name, artist: '', album: t.show?.name ?? '', status: data.is_playing ? 'playing' : 'paused', progress: '' }]; const prog = (data.progress_ms ?? 0) / 1000 | 0; const dur = t.duration_ms / 1000 | 0; const fmt = (s) => `${s / 60 | 0}:${String(s % 60).padStart(2, '0')}`; return [{ track: t.name, artist: t.artists.map((a) => a.name).join(', '), album: t.album.name, status: data.is_playing ? 'playing' : 'paused', progress: `${fmt(prog)} / ${fmt(dur)}` }]; }, }); cli({ site: 'spotify', name: 'play', access: 'write', description: 'Resume playback or search and play a track/artist', strategy: Strategy.PUBLIC, browser: false, args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }], columns: ['track', 'artist', 'status'], func: async (kwargs) => { if (kwargs.query) { const { uri, name, artist } = await findTrackUri(kwargs.query); await api('PUT', '/me/player/play', { uris: [uri] }); return [{ track: name, artist, status: 'playing' }]; } await api('PUT', '/me/player/play'); return [{ track: '', artist: '', status: 'resumed' }]; }, }); cli({ site: 'spotify', name: 'pause', access: 'write', description: 'Pause playback', strategy: Strategy.PUBLIC, browser: false, args: [], columns: ['status'], func: async () => { await api('PUT', '/me/player/pause'); return [{ status: 'paused' }]; }, }); cli({ site: 'spotify', name: 'next', access: 'write', description: 'Skip to next track', strategy: Strategy.PUBLIC, browser: false, args: [], columns: ['status'], func: async () => { await api('POST', '/me/player/next'); return [{ status: 'skipped to next' }]; }, }); cli({ site: 'spotify', name: 'prev', access: 'write', description: 'Skip to previous track', strategy: Strategy.PUBLIC, browser: false, args: [], columns: ['status'], func: async () => { await api('POST', '/me/player/previous'); return [{ status: 'skipped to previous' }]; }, }); cli({ site: 'spotify', name: 'volume', access: 'write', description: 'Set playback volume (0-100)', strategy: Strategy.PUBLIC, browser: false, args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }], columns: ['volume'], func: async (kwargs) => { const level = Math.round(kwargs.level); if (level < 0 || level > 100) throw new CliError('INVALID_ARGS', 'Volume must be between 0 and 100'); await api('PUT', `/me/player/volume?volume_percent=${level}`); return [{ volume: `${level}%` }]; }, }); cli({ site: 'spotify', name: 'search', access: 'read', description: 'Search for tracks', strategy: Strategy.PUBLIC, browser: false, args: [ { name: 'query', type: 'str', required: true, positional: true, help: 'Search query' }, { name: 'limit', type: 'int', default: 10, help: 'Number of results (default: 10)' }, ], columns: ['track', 'artist', 'album', 'uri'], func: async (kwargs) => { const limit = Math.min(50, Math.max(1, Math.round(kwargs.limit))); const data = await api('GET', `/search?q=${encodeURIComponent(kwargs.query)}&type=track&limit=${limit}`); const results = mapSpotifyTrackResults(data); if (!results.length) throw new CliError('EMPTY_RESULT', `No results found for: ${kwargs.query}`); return results; }, }); cli({ site: 'spotify', name: 'queue', access: 'write', description: 'Add a track to the playback queue', strategy: Strategy.PUBLIC, browser: false, args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }], columns: ['track', 'artist', 'status'], func: async (kwargs) => { const { uri, name, artist } = await findTrackUri(kwargs.query); await api('POST', `/me/player/queue?uri=${encodeURIComponent(uri)}`); return [{ track: name, artist, status: 'added to queue' }]; }, }); cli({ site: 'spotify', name: 'shuffle', access: 'write', description: 'Toggle shuffle on/off', strategy: Strategy.PUBLIC, browser: false, args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }], columns: ['shuffle'], func: async (kwargs) => { await api('PUT', `/me/player/shuffle?state=${kwargs.state === 'on'}`); return [{ shuffle: kwargs.state }]; }, }); cli({ site: 'spotify', name: 'repeat', access: 'write', description: 'Set repeat mode (off / track / context)', strategy: Strategy.PUBLIC, browser: false, args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }], columns: ['repeat'], func: async (kwargs) => { await api('PUT', `/me/player/repeat?state=${kwargs.mode}`); return [{ repeat: kwargs.mode }]; }, });