UNPKG

commit-vibes

Version:

A CLI tool to add vibes to your Git commit messages

449 lines (393 loc) • 14.9 kB
import SpotifyWebApi from "spotify-web-api-node"; import open from "open"; import http from "http"; import { URL } from "url"; import { existsSync, readFileSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import dotenv from "dotenv"; import { saveTokens, deleteTokens, loadTokensFromStorage, } from "./spotify-tokens.js"; import chalk from "chalk"; // Get the directory of the current module const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Load environment variables from .env file (if it exists) // Try to load from project root (where package.json is) const envPath = join(__dirname, "..", "..", ".env"); dotenv.config({ path: envPath }); // Also try loading from current working directory as fallback dotenv.config(); // Load Spotify config with error handling (lazy loading) let SPOTIFY_CONFIG = null; let spotifyApi = null; let configLoadAttempted = false; // Lazy load Spotify configuration (only when needed) // Supports both .env file and config/spotify.js file async function loadSpotifyConfig() { // Only try to load once if (configLoadAttempted) { return SPOTIFY_CONFIG !== null; } configLoadAttempted = true; // First, try to load from environment variables (.env file) const envClientId = process.env.SPOTIFY_CLIENT_ID?.trim(); const envClientSecret = process.env.SPOTIFY_CLIENT_SECRET?.trim(); const envRedirectUri = (process.env.SPOTIFY_REDIRECT_URI || "http://127.0.0.1:3000").trim(); if (envClientId && envClientSecret) { // Use environment variables SPOTIFY_CONFIG = { clientId: envClientId, clientSecret: envClientSecret, redirectUri: envRedirectUri, }; spotifyApi = new SpotifyWebApi({ clientId: SPOTIFY_CONFIG.clientId, clientSecret: SPOTIFY_CONFIG.clientSecret, redirectUri: SPOTIFY_CONFIG.redirectUri, }); // Load existing tokens if available const existingTokens = await loadTokensFromStorage(); if (existingTokens) { spotifyApi.setAccessToken(existingTokens.access_token); spotifyApi.setRefreshToken(existingTokens.refresh_token); } return true; } // If not in .env, try to load from config file const configPath = join(__dirname, "config", "spotify.js"); // Check if config file exists if (!existsSync(configPath)) { // Neither .env nor config file exists - Spotify is not configured // This is fine, the tool can work without Spotify return false; } try { // Read and evaluate the config file manually to avoid ES module resolution issues const configContent = readFileSync(configPath, "utf-8"); // Extract the SPOTIFY_CONFIG export using regex // This is safer than eval and avoids module resolution issues const configMatch = configContent.match(/export\s+const\s+SPOTIFY_CONFIG\s*=\s*({[\s\S]*?});/); if (!configMatch || !configMatch[1]) { return false; } // Safely evaluate the config object // Using Function constructor is safer than eval for this use case const configObj = new Function(`return ${configMatch[1]}`)(); SPOTIFY_CONFIG = configObj; if (!SPOTIFY_CONFIG || !SPOTIFY_CONFIG.clientId || !SPOTIFY_CONFIG.clientSecret) { return false; } spotifyApi = new SpotifyWebApi({ clientId: SPOTIFY_CONFIG.clientId, clientSecret: SPOTIFY_CONFIG.clientSecret, redirectUri: SPOTIFY_CONFIG.redirectUri, }); // Load existing tokens if available const existingTokens = await loadTokensFromStorage(); if (existingTokens) { spotifyApi.setAccessToken(existingTokens.access_token); spotifyApi.setRefreshToken(existingTokens.refresh_token); } return true; } catch (error) { // Handle all errors gracefully - file read errors, syntax errors, etc. // This is fine, Spotify is optional return false; } } // Generate random state for security function generateRandomString(length) { const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let text = ""; for (let i = 0; i < length; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } // Check if Spotify is configured (with helpful error message) async function ensureSpotifyConfigured() { // Try to load config if not already attempted const isConfigured = await loadSpotifyConfig(); if (!isConfigured || !SPOTIFY_CONFIG || !spotifyApi) { const configPath = join(__dirname, "config", "spotify.js"); const templatePath = join(__dirname, "config", "spotify.template.js"); const errorMessage = `\nāŒ Spotify is not configured!\n\n` + `To use Spotify features, you need to configure Spotify credentials.\n\n` + `Option 1: Use environment variables (recommended)\n` + ` Add to your .env file:\n` + ` SPOTIFY_CLIENT_ID=your_client_id\n` + ` SPOTIFY_CLIENT_SECRET=your_client_secret\n` + ` SPOTIFY_REDIRECT_URI=http://127.0.0.1:3000\n\n` + `Option 2: Use config file\n` + ` 1. Copy the template file:\n` + ` cp "${templatePath}" "${configPath}"\n\n` + ` 2. Edit ${configPath} and add your Spotify API credentials.\n\n` + `Get credentials from: https://developer.spotify.com/dashboard\n\n` + `Note: You can use commit-vibes without Spotify - it's optional!\n`; throw new Error(errorMessage); } } // Generate a random state string function generateState() { return Math.random().toString(36).substring(7); } // Refresh token if expired async function refreshTokenIfNeeded() { await ensureSpotifyConfigured(); let tokens; try { tokens = await loadTokensFromStorage(); if (!tokens) return false; spotifyApi.setAccessToken(tokens.access_token); spotifyApi.setRefreshToken(tokens.refresh_token); // Try to get current track to check if token is expired await spotifyApi.getMyCurrentPlayingTrack(); return true; } catch (error) { if (error.statusCode === 401 && tokens) { try { // Token expired, refresh it const data = await spotifyApi.refreshAccessToken(); const newTokens = { access_token: data.body.access_token, refresh_token: tokens.refresh_token, }; await saveTokens(newTokens); spotifyApi.setAccessToken(newTokens.access_token); return true; } catch (refreshError) { console.error("Error refreshing token:", refreshError); return false; } } return false; } } // Start the auth flow export async function startAuthFlow() { await ensureSpotifyConfigured(); const state = generateRandomString(16); const scopes = ["user-read-currently-playing", "user-read-recently-played"]; // Create the authorization URL with state const params = new URLSearchParams({ client_id: SPOTIFY_CONFIG.clientId, response_type: "code", redirect_uri: SPOTIFY_CONFIG.redirectUri, state: state, scope: scopes.join(" "), }); const authorizeURL = `https://accounts.spotify.com/authorize?${params.toString()}`; // Create a temporary server to handle the callback const server = http.createServer(async (req, res) => { try { // Use 127.0.0.1 for the base URL since Spotify requires IP literal, not localhost const baseUrl = SPOTIFY_CONFIG.redirectUri.replace('localhost', '127.0.0.1'); const url = new URL(req.url, baseUrl); const code = url.searchParams.get("code"); const returnedState = url.searchParams.get("state"); if (state !== returnedState) { res.writeHead(400); res.end("State mismatch!"); server.close(); return; } // Exchange code for tokens const data = await spotifyApi.authorizationCodeGrant(code); const { access_token, refresh_token } = data.body; // Set the tokens spotifyApi.setAccessToken(access_token); spotifyApi.setRefreshToken(refresh_token); // Save tokens await saveTokens({ access_token, refresh_token }); // Send success response res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <html> <head> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>šŸŽµ</text></svg>"> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #1DB954; color: white; margin: 0; padding: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; } .container { text-align: center; padding: 2rem; background: rgba(255, 255, 255, 0.1); border-radius: 12px; backdrop-filter: blur(10px); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } h1 { font-size: 2.5rem; margin-bottom: 1rem; color: white; } p { font-size: 1.1rem; opacity: 0.9; margin: 0; } .spotify-logo { width: 48px; height: 48px; margin-bottom: 1rem; } </style> </head> <body> <div class="container"> <svg class="spotify-logo" viewBox="0 0 24 24" fill="white"> <path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/> </svg> <h1>Successfully Connected!</h1> <p>Your Spotify account is now linked to Commit Vibes</p> <p style="font-size: 0.9rem; margin-top: 1rem; opacity: 0.7;">You can close this window and return to the terminal</p> </div> </body> </html> `); server.close(); } catch (error) { console.error(chalk.red("āŒ Error during authentication:")); console.error(chalk.red(` Message: ${error.message}`)); console.error(chalk.red(` Status: ${error.statusCode || 'N/A'}`)); console.error(chalk.red(` Body: ${JSON.stringify(error.body || error, null, 2)}`)); console.error(chalk.dim(` Redirect URI used: "${SPOTIFY_CONFIG.redirectUri}"`)); res.writeHead(400); res.end(`Error during authentication: ${error.message}`); server.close(); } }); // Start the server server.listen(3000, () => { console.log(chalk.blue("\nšŸŽµ Connecting to Spotify...")); // Open in Chrome (or use default browser if Chrome not found) open(authorizeURL, { app: { name: 'google chrome' } }).catch(() => { // Fallback to default browser if Chrome not found open(authorizeURL); }); }); return new Promise((resolve, reject) => { server.on("close", () => { resolve(spotifyApi); }); server.on("error", (error) => { reject(error); }); }); } // Format relative time function formatRelativeTime(timestamp) { const now = new Date(); const played = new Date(timestamp); const diffInSeconds = Math.floor((now - played) / 1000); if (diffInSeconds < 60) return "just now"; if (diffInSeconds < 3600) { const mins = Math.floor(diffInSeconds / 60); return `${mins} ${mins === 1 ? "min" : "mins"} ago`; } if (diffInSeconds < 86400) { const hours = Math.floor(diffInSeconds / 3600); return `${hours} ${hours === 1 ? "hour" : "hours"} ago`; } const days = Math.floor(diffInSeconds / 86400); return `${days} ${days === 1 ? "day" : "days"} ago`; } // Get currently playing track export async function getCurrentTrack() { // Try to load config, but don't throw error if not configured const isConfigured = await loadSpotifyConfig(); if (!isConfigured || !spotifyApi) { // Spotify not configured - return null to indicate no Spotify data return null; } try { // Check and refresh token if needed const isTokenValid = await refreshTokenIfNeeded(); if (!isTokenValid) { return { error: "auth" }; } const data = await spotifyApi.getMyCurrentPlayingTrack(); if (data.body && data.body.item) { return { current: { name: data.body.item.name, artist: data.body.item.artists[0].name, }, }; } // No song playing, get recent tracks const recentTracks = await getRecentTracks(3); return { current: null, recent: recentTracks, }; } catch (error) { if (error.statusCode === 401) { return { error: "auth" }; } return { error: "api" }; } } // Get recently played tracks export async function getRecentTracks(limit = 3) { // Try to load config, but don't throw error if not configured const isConfigured = await loadSpotifyConfig(); if (!isConfigured || !spotifyApi) { // Spotify not configured - return empty array return []; } try { const data = await spotifyApi.getMyRecentlyPlayedTracks({ limit }); return data.body.items.map((item) => ({ name: item.track.name, artist: item.track.artists[0].name, playedAt: formatRelativeTime(item.played_at), })); } catch (error) { console.error("Error getting recent tracks:", error); return []; } } // Disconnect from Spotify export async function disconnectSpotify() { if (!spotifyApi) { // If not configured, tokens might still exist, so try to delete them await deleteTokens(); return true; } try { await deleteTokens(); spotifyApi.setAccessToken(null); spotifyApi.setRefreshToken(null); return true; } catch (error) { console.error("Error disconnecting from Spotify:", error); return false; } } // Load tokens from storage export async function loadTokens() { if (!spotifyApi) return false; const tokens = await loadTokensFromStorage(); if (tokens) { spotifyApi.setAccessToken(tokens.access_token); spotifyApi.setRefreshToken(tokens.refresh_token); return true; } return false; }