From 833d3342447cdce5eb091e4b9078223ebf08710e Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Thu, 14 May 2026 13:02:16 +0200 Subject: [PATCH] feat(spotify): extract spotify package and add login, logout, speaker commands --- bun.lock | 42 +++++ docs/commands/spotify.md | 52 ++++++ package.json | 3 +- packages/cli/package.json | 1 + packages/cli/src/commands/spotify/index.ts | 8 + packages/cli/src/commands/spotify/launch.ts | 2 +- packages/cli/src/commands/spotify/login.ts | 33 ++++ packages/cli/src/commands/spotify/logout.ts | 15 ++ packages/cli/src/commands/spotify/next.ts | 2 +- packages/cli/src/commands/spotify/now.ts | 2 +- packages/cli/src/commands/spotify/pause.ts | 2 +- packages/cli/src/commands/spotify/play.ts | 2 +- .../cli/src/commands/spotify/playpause.ts | 2 +- packages/cli/src/commands/spotify/previous.ts | 2 +- packages/cli/src/commands/spotify/speaker.ts | 53 ++++++ packages/cli/src/commands/spotify/speakers.ts | 21 +++ .../cli/src/commands/spotify/spotify.spec.ts | 148 ++++++++++++++- .../cli/src/commands/spotify/volume-down.ts | 2 +- .../cli/src/commands/spotify/volume-up.ts | 2 +- packages/cli/src/plugins/macos.spec.ts | 17 +- packages/cli/tsconfig.json | 4 +- packages/spotify/package.json | 23 +++ packages/spotify/src/app.spec.ts | 15 ++ packages/spotify/src/app.ts | 7 + packages/spotify/src/auth.spec.ts | 152 ++++++++++++++++ packages/spotify/src/auth.ts | 172 ++++++++++++++++++ packages/spotify/src/devices.spec.ts | 70 +++++++ packages/spotify/src/devices.ts | 54 ++++++ packages/spotify/src/errors.ts | 13 ++ .../src/playback.spec.ts} | 16 +- .../spotify.ts => spotify/src/playback.ts} | 6 +- packages/spotify/tsconfig.json | 10 + packages/spotify/vitest.config.ts | 8 + packages/typescript-config/base.json | 2 +- 34 files changed, 928 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/commands/spotify/login.ts create mode 100644 packages/cli/src/commands/spotify/logout.ts create mode 100644 packages/cli/src/commands/spotify/speaker.ts create mode 100644 packages/cli/src/commands/spotify/speakers.ts create mode 100644 packages/spotify/package.json create mode 100644 packages/spotify/src/app.spec.ts create mode 100644 packages/spotify/src/app.ts create mode 100644 packages/spotify/src/auth.spec.ts create mode 100644 packages/spotify/src/auth.ts create mode 100644 packages/spotify/src/devices.spec.ts create mode 100644 packages/spotify/src/devices.ts create mode 100644 packages/spotify/src/errors.ts rename packages/{core/src/spotify.spec.ts => spotify/src/playback.spec.ts} (82%) rename packages/{core/src/spotify.ts => spotify/src/playback.ts} (90%) create mode 100644 packages/spotify/tsconfig.json create mode 100644 packages/spotify/vitest.config.ts diff --git a/bun.lock b/bun.lock index 602467c..27c1c72 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ }, "dependencies": { "@bitbard/core": "workspace:*", + "@bitbard/spotify": "workspace:*", "@clack/prompts": "catalog:", "chalk": "catalog:", "citty": "catalog:", @@ -48,6 +49,20 @@ "vitest": "catalog:", }, }, + "packages/spotify": { + "name": "@bitbard/spotify", + "dependencies": { + "@bitbard/core": "workspace:*", + "open": "catalog:", + }, + "devDependencies": { + "@bitbard/typescript-config": "workspace:*", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/typescript-config": { "name": "@bitbard/typescript-config", }, @@ -58,6 +73,7 @@ "@vitest/coverage-v8": "^4.1.5", "chalk": "^5.6.2", "citty": "^0.2.2", + "open": "^11.0.0", "oxfmt": "^0.48.0", "oxlint": "^1.62.0", "rolldown": "^1.0.0", @@ -82,6 +98,8 @@ "@bitbard/core": ["@bitbard/core@workspace:packages/core"], + "@bitbard/spotify": ["@bitbard/spotify@workspace:packages/spotify"], + "@bitbard/typescript-config": ["@bitbard/typescript-config@workspace:packages/typescript-config"], "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], @@ -256,6 +274,8 @@ "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -264,6 +284,12 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], @@ -286,6 +312,14 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], @@ -328,6 +362,8 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "oxfmt": ["oxfmt@0.48.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.48.0", "@oxfmt/binding-android-arm64": "0.48.0", "@oxfmt/binding-darwin-arm64": "0.48.0", "@oxfmt/binding-darwin-x64": "0.48.0", "@oxfmt/binding-freebsd-x64": "0.48.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.48.0", "@oxfmt/binding-linux-arm-musleabihf": "0.48.0", "@oxfmt/binding-linux-arm64-gnu": "0.48.0", "@oxfmt/binding-linux-arm64-musl": "0.48.0", "@oxfmt/binding-linux-ppc64-gnu": "0.48.0", "@oxfmt/binding-linux-riscv64-gnu": "0.48.0", "@oxfmt/binding-linux-riscv64-musl": "0.48.0", "@oxfmt/binding-linux-s390x-gnu": "0.48.0", "@oxfmt/binding-linux-x64-gnu": "0.48.0", "@oxfmt/binding-linux-x64-musl": "0.48.0", "@oxfmt/binding-openharmony-arm64": "0.48.0", "@oxfmt/binding-win32-arm64-msvc": "0.48.0", "@oxfmt/binding-win32-ia32-msvc": "0.48.0", "@oxfmt/binding-win32-x64-msvc": "0.48.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g=="], "oxlint": ["oxlint@1.63.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.63.0", "@oxlint/binding-android-arm64": "1.63.0", "@oxlint/binding-darwin-arm64": "1.63.0", "@oxlint/binding-darwin-x64": "1.63.0", "@oxlint/binding-freebsd-x64": "1.63.0", "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", "@oxlint/binding-linux-arm-musleabihf": "1.63.0", "@oxlint/binding-linux-arm64-gnu": "1.63.0", "@oxlint/binding-linux-arm64-musl": "1.63.0", "@oxlint/binding-linux-ppc64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-musl": "1.63.0", "@oxlint/binding-linux-s390x-gnu": "1.63.0", "@oxlint/binding-linux-x64-gnu": "1.63.0", "@oxlint/binding-linux-x64-musl": "1.63.0", "@oxlint/binding-openharmony-arm64": "1.63.0", "@oxlint/binding-win32-arm64-msvc": "1.63.0", "@oxlint/binding-win32-ia32-msvc": "1.63.0", "@oxlint/binding-win32-x64-msvc": "1.63.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg=="], @@ -340,8 +376,12 @@ "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "rolldown": ["rolldown@1.0.0", "", { "dependencies": { "@oxc-project/types": "=0.129.0", "@rolldown/pluginutils": "1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0", "@rolldown/binding-darwin-arm64": "1.0.0", "@rolldown/binding-darwin-x64": "1.0.0", "@rolldown/binding-freebsd-x64": "1.0.0", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", "@rolldown/binding-linux-arm64-gnu": "1.0.0", "@rolldown/binding-linux-arm64-musl": "1.0.0", "@rolldown/binding-linux-ppc64-gnu": "1.0.0", "@rolldown/binding-linux-s390x-gnu": "1.0.0", "@rolldown/binding-linux-x64-gnu": "1.0.0", "@rolldown/binding-linux-x64-musl": "1.0.0", "@rolldown/binding-openharmony-arm64": "1.0.0", "@rolldown/binding-wasm32-wasi": "1.0.0", "@rolldown/binding-win32-arm64-msvc": "1.0.0", "@rolldown/binding-win32-x64-msvc": "1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -380,6 +420,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "vite/rolldown": ["rolldown@1.0.0-rc.18", "", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="], "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.128.0", "", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="], diff --git a/docs/commands/spotify.md b/docs/commands/spotify.md index e37c0dd..e20d8e3 100644 --- a/docs/commands/spotify.md +++ b/docs/commands/spotify.md @@ -2,6 +2,8 @@ Control the Spotify application. +Most commands send instructions directly to the Spotify app on your Mac via AppleScript. The [speakers](#speakers) and [speaker](#speaker) commands use the [Spotify Web API](https://developer.spotify.com/documentation/web-api) and require a Spotify Developer account and a client ID — see [login](#login) for setup. + ## Table of Contents - [launch](#launch) @@ -13,6 +15,10 @@ Control the Spotify application. - [now](#now) - [volume-up](#volume-up) - [volume-down](#volume-down) +- [login](#login) +- [logout](#logout) +- [speakers](#speakers) +- [speaker](#speaker) ## launch @@ -85,3 +91,49 @@ Decrease Spotify volume by 5. ```sh bitbard spotify volume-down ``` + +## login + +Log in to Spotify via OAuth to enable Web API commands. Requires a client ID from a [Spotify Developer app](https://developer.spotify.com/dashboard). + +```sh +bitbard spotify login --client-id +``` + +This opens a browser window to complete the OAuth flow. Once authenticated, credentials are stored in the macOS Keychain and reused by subsequent commands. If you are already logged in, the command exits early with a reminder to run `logout` first if you want to switch accounts. + +## logout + +Log out of Spotify, removing stored credentials. + +```sh +bitbard spotify logout +``` + +## speakers + +List available [Spotify Connect](https://www.spotify.com/connect/) devices. + +```sh +bitbard spotify speakers +``` + +Devices are retrieved via the Spotify Web API and must be active (i.e. have Spotify open) to appear. Requires [login](#login). + +> **Note:** Only devices that support Spotify Connect are listed. Some speaker types may not appear due to limitations of the Spotify Web API. + +## speaker + +Switch Spotify playback to a different device. + +```sh +# Interactive — shows a prompt to pick a device +bitbard spotify speaker + +# Direct — transfer to a specific device by ID +bitbard spotify speaker +``` + +If no device ID is provided, an interactive prompt lists all available devices and their active state. Requires [login](#login). + +> **Note:** Only devices that support Spotify Connect are listed. Some speaker types may not appear due to limitations of the Spotify Web API. diff --git a/package.json b/package.json index 14a8420..de79e35 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bitbard", "private": true, "type": "module", - "packageManager": "bun@1.3.13", + "packageManager": "bun@1.3.14", "workspaces": { "packages": [ "packages/*" @@ -13,6 +13,7 @@ "@vitest/coverage-v8": "^4.1.5", "chalk": "^5.6.2", "citty": "^0.2.2", + "open": "^11.0.0", "oxfmt": "^0.48.0", "oxlint": "^1.62.0", "rolldown": "^1.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 702c83a..ea61466 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@bitbard/core": "workspace:*", + "@bitbard/spotify": "workspace:*", "@clack/prompts": "catalog:", "chalk": "catalog:", "citty": "catalog:" diff --git a/packages/cli/src/commands/spotify/index.ts b/packages/cli/src/commands/spotify/index.ts index 21ff31c..8935536 100644 --- a/packages/cli/src/commands/spotify/index.ts +++ b/packages/cli/src/commands/spotify/index.ts @@ -8,7 +8,11 @@ import next from './next.js'; import now from './now.js'; import volumeUp from './volume-up.js'; import volumeDown from './volume-down.js'; +import login from './login.js'; +import logout from './logout.js'; import { macosPlugin } from '../../plugins/macos.js'; +import speakers from './speakers.js'; +import speaker from './speaker.js'; export default defineCommand({ meta: { @@ -17,11 +21,15 @@ export default defineCommand({ }, plugins: [macosPlugin], subCommands: { + login, + logout, launch, play, pause, playpause, previous, + speakers, + speaker, next, now, 'volume-up': volumeUp, diff --git a/packages/cli/src/commands/spotify/launch.ts b/packages/cli/src/commands/spotify/launch.ts index 9568291..9a639bd 100644 --- a/packages/cli/src/commands/spotify/launch.ts +++ b/packages/cli/src/commands/spotify/launch.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { launch } from '@bitbard/core/spotify.js'; +import { launch } from '@bitbard/spotify/app.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/login.ts b/packages/cli/src/commands/spotify/login.ts new file mode 100644 index 0000000..f3a91ee --- /dev/null +++ b/packages/cli/src/commands/spotify/login.ts @@ -0,0 +1,33 @@ +import { defineCommand } from 'citty'; +import { login, isLoggedIn } from '@bitbard/spotify/auth.js'; +import { spinner } from '@clack/prompts'; + +export default defineCommand({ + meta: { + name: 'login', + description: 'Log in to Spotify via OAuth (requires your Spotify Developer client ID)', + }, + args: { + 'client-id': { + type: 'string', + description: 'Your Spotify Developer app client ID', + required: true, + }, + }, + async run({ args }) { + if (await isLoggedIn()) { + console.log('Already logged in to Spotify. Run: bitbard spotify logout to switch accounts.'); + return; + } + + const s = spinner(); + s.start('Waiting for Spotify login in browser…'); + try { + await login(args['client-id']); + } catch (err) { + s.stop('Login failed'); + throw err; + } + s.stop('Logged in to Spotify'); + }, +}); diff --git a/packages/cli/src/commands/spotify/logout.ts b/packages/cli/src/commands/spotify/logout.ts new file mode 100644 index 0000000..40d0940 --- /dev/null +++ b/packages/cli/src/commands/spotify/logout.ts @@ -0,0 +1,15 @@ +import { defineCommand } from 'citty'; +import { logout } from '@bitbard/spotify/auth.js'; +import { log } from '@clack/prompts'; + +export default defineCommand({ + meta: { + name: 'logout', + description: 'Log out of Spotify', + }, + async run() { + await logout(); + + log.success('Logged out of Spotify'); + }, +}); diff --git a/packages/cli/src/commands/spotify/next.ts b/packages/cli/src/commands/spotify/next.ts index a3347af..ba2ba83 100644 --- a/packages/cli/src/commands/spotify/next.ts +++ b/packages/cli/src/commands/spotify/next.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { nextTrack } from '@bitbard/core/spotify.js'; +import { nextTrack } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/now.ts b/packages/cli/src/commands/spotify/now.ts index 48aa896..c79501c 100644 --- a/packages/cli/src/commands/spotify/now.ts +++ b/packages/cli/src/commands/spotify/now.ts @@ -1,6 +1,6 @@ import { defineCommand } from 'citty'; import chalk from 'chalk'; -import { getNowPlaying } from '@bitbard/core/spotify.js'; +import { getNowPlaying } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/pause.ts b/packages/cli/src/commands/spotify/pause.ts index 43c0016..c77cdaf 100644 --- a/packages/cli/src/commands/spotify/pause.ts +++ b/packages/cli/src/commands/spotify/pause.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { pause } from '@bitbard/core/spotify.js'; +import { pause } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/play.ts b/packages/cli/src/commands/spotify/play.ts index cdc3a68..8cd14d1 100644 --- a/packages/cli/src/commands/spotify/play.ts +++ b/packages/cli/src/commands/spotify/play.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { play } from '@bitbard/core/spotify.js'; +import { play } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/playpause.ts b/packages/cli/src/commands/spotify/playpause.ts index d8d476a..da269aa 100644 --- a/packages/cli/src/commands/spotify/playpause.ts +++ b/packages/cli/src/commands/spotify/playpause.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { playpause } from '@bitbard/core/spotify.js'; +import { playpause } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/previous.ts b/packages/cli/src/commands/spotify/previous.ts index 331b750..73f1fbd 100644 --- a/packages/cli/src/commands/spotify/previous.ts +++ b/packages/cli/src/commands/spotify/previous.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { previousTrack } from '@bitbard/core/spotify.js'; +import { previousTrack } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/speaker.ts b/packages/cli/src/commands/spotify/speaker.ts new file mode 100644 index 0000000..3861be3 --- /dev/null +++ b/packages/cli/src/commands/spotify/speaker.ts @@ -0,0 +1,53 @@ +import { defineCommand } from 'citty'; +import { select, isCancel } from '@clack/prompts'; +import chalk from 'chalk'; +import { getDevices, transferPlayback } from '@bitbard/spotify/devices.js'; + +export default defineCommand({ + meta: { + name: 'speaker', + description: 'Switch Spotify playback to a device (interactive if no ID given)', + }, + args: { + id: { + type: 'positional', + description: 'Spotify device ID (optional — shows list if omitted)', + required: false, + }, + }, + async run({ args }) { + const devices = await getDevices(); + + if (devices.length === 0) { + console.log('No devices found. Open Spotify on a device first.'); + return; + } + + if (args.id) { + const device = devices.find((d) => d.id === args.id); + if (!device) { + console.log(chalk.red(`No device with ID "${args.id}" found.`)); + return; + } + await transferPlayback(device.id); + console.log(chalk.bold(chalk.green(`Playback transferred to ${device.name}.`))); + return; + } + + const choice = await select({ + message: 'Select a device', + options: devices.map((d) => ({ + value: d.id, + label: `${d.name} ${chalk.dim(d.type)}${d.isActive ? chalk.green(' (active)') : ''}`, + })), + }); + + if (isCancel(choice)) { + return; + } + + const selected = devices.find((d) => d.id === choice)!; + await transferPlayback(choice as string); + console.log(chalk.bold(chalk.green(`Playback transferred to ${selected.name}.`))); + }, +}); diff --git a/packages/cli/src/commands/spotify/speakers.ts b/packages/cli/src/commands/spotify/speakers.ts new file mode 100644 index 0000000..119af98 --- /dev/null +++ b/packages/cli/src/commands/spotify/speakers.ts @@ -0,0 +1,21 @@ +import { defineCommand } from 'citty'; +import chalk from 'chalk'; +import { getDevices } from '@bitbard/spotify/devices.js'; + +export default defineCommand({ + meta: { + name: 'speakers', + description: 'List available Spotify Connect devices', + }, + async run() { + const devices = await getDevices(); + if (devices.length === 0) { + console.log('No devices found. Open Spotify on a device first.'); + return; + } + for (const device of devices) { + const active = device.isActive ? chalk.green(' (active)') : ''; + console.log(` ${chalk.bold(device.name.padEnd(20))} ${chalk.dim(device.type)}${active}`); + } + }, +}); diff --git a/packages/cli/src/commands/spotify/spotify.spec.ts b/packages/cli/src/commands/spotify/spotify.spec.ts index 54f5d1b..8146812 100644 --- a/packages/cli/src/commands/spotify/spotify.spec.ts +++ b/packages/cli/src/commands/spotify/spotify.spec.ts @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { runCommand } from 'citty'; -vi.mock('@bitbard/core/spotify.js', () => ({ +vi.mock('@bitbard/spotify/app.js', () => ({ launch: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@bitbard/spotify/playback.js', () => ({ playpause: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined), pause: vi.fn().mockResolvedValue(undefined), @@ -13,7 +16,31 @@ vi.mock('@bitbard/core/spotify.js', () => ({ volumeDown: vi.fn().mockResolvedValue(undefined), })); -import * as spotifyUtil from '@bitbard/core/spotify.js'; +// Add a new vi.mock block (alongside the existing @bitbard/core/spotify.js mock). +// Do NOT replace or duplicate the existing beforeEach(() => vi.clearAllMocks()) — leave it as-is. +vi.mock('@bitbard/spotify/auth.js', () => ({ + login: vi.fn().mockResolvedValue(undefined), + logout: vi.fn().mockResolvedValue(undefined), + isLoggedIn: vi.fn().mockResolvedValue(false), +})); + +vi.mock('@bitbard/spotify/devices.js', () => ({ + getDevices: vi.fn(), + transferPlayback: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@clack/prompts', () => ({ + select: vi.fn(), + isCancel: vi.fn(), + log: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, + spinner: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })), +})); + +import * as spotifyApp from '@bitbard/spotify/app.js'; +import * as spotifyUtil from '@bitbard/spotify/playback.js'; +import * as spotifyAuth from '@bitbard/spotify/auth.js'; +import * as spotifyDevices from '@bitbard/spotify/devices.js'; +import * as clack from '@clack/prompts'; import launchCmd from './launch.js'; import playCmd from './play.js'; @@ -22,13 +49,22 @@ import nextCmd from './next.js'; import nowCmd from './now.js'; import volumeUpCmd from './volume-up.js'; import volumeDownCmd from './volume-down.js'; +import loginCmd from './login.js'; +import logoutCmd from './logout.js'; +import speakersCmd from './speakers.js'; +import speakerCmd from './speaker.js'; + +const mockDevices = [ + { id: 'abc', name: 'MacBook Pro', type: 'Computer', isActive: true, volumePercent: 80 }, + { id: 'def', name: 'Living Room', type: 'Speaker', isActive: false, volumePercent: 50 }, +]; beforeEach(() => vi.clearAllMocks()); describe('spotify launch', () => { it('calls launch()', async () => { await runCommand(launchCmd, { rawArgs: [] }); - expect(spotifyUtil.launch).toHaveBeenCalledOnce(); + expect(spotifyApp.launch).toHaveBeenCalledOnce(); }); }); @@ -84,3 +120,109 @@ describe('spotify volume-down', () => { expect(spotifyUtil.volumeDown).toHaveBeenCalledOnce(); }); }); + +describe('spotify login', () => { + it('calls login() with provided client-id', async () => { + await runCommand(loginCmd, { rawArgs: ['--client-id', 'my-id'] }); + expect(spotifyAuth.login).toHaveBeenCalledWith('my-id'); + }); + + it('prints message and skips login if already logged in', async () => { + vi.mocked(spotifyAuth.isLoggedIn).mockResolvedValueOnce(true); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runCommand(loginCmd, { rawArgs: ['--client-id', 'my-id'] }); + expect(spotifyAuth.login).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Already')); + logSpy.mockRestore(); + }); +}); + +describe('spotify logout', () => { + it('calls logout()', async () => { + await runCommand(logoutCmd, { rawArgs: [] }); + expect(spotifyAuth.logout).toHaveBeenCalledOnce(); + }); +}); + +describe('spotify speakers', () => { + it('prints device list with active marker', async () => { + vi.mocked(spotifyDevices.getDevices).mockResolvedValue(mockDevices); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runCommand(speakersCmd, { rawArgs: [] }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('MacBook Pro')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Living Room')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('active')); + logSpy.mockRestore(); + }); + + it('prints message when no devices found', async () => { + vi.mocked(spotifyDevices.getDevices).mockResolvedValue([]); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runCommand(speakersCmd, { rawArgs: [] }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No devices found')); + logSpy.mockRestore(); + }); +}); + +describe('spotify speaker (no argument)', () => { + it('shows select prompt and transfers playback on selection', async () => { + vi.mocked(spotifyDevices.getDevices).mockResolvedValue(mockDevices); + vi.mocked(clack.isCancel).mockReturnValue(false); + vi.mocked(clack.select).mockResolvedValue('def'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(speakerCmd, { rawArgs: [] }); + + expect(clack.select).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ value: 'abc' }), + expect.objectContaining({ value: 'def' }), + ]), + }), + ); + expect(spotifyDevices.transferPlayback).toHaveBeenCalledWith('def'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Living Room')); + logSpy.mockRestore(); + }); + + it('exits silently on cancel', async () => { + vi.mocked(spotifyDevices.getDevices).mockResolvedValue(mockDevices); + vi.mocked(clack.select).mockResolvedValue(Symbol('cancel')); + vi.mocked(clack.isCancel).mockReturnValue(true); + + await runCommand(speakerCmd, { rawArgs: [] }); + + expect(spotifyDevices.transferPlayback).not.toHaveBeenCalled(); + }); + + it('prints message when no devices available', async () => { + vi.mocked(spotifyDevices.getDevices).mockResolvedValue([]); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runCommand(speakerCmd, { rawArgs: [] }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No devices found')); + expect(clack.select).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); + +describe('spotify speaker ', () => { + it('transfers to matching device without showing prompt', async () => { + vi.mocked(spotifyDevices.getDevices).mockResolvedValue(mockDevices); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runCommand(speakerCmd, { rawArgs: ['abc'] }); + expect(clack.select).not.toHaveBeenCalled(); + expect(spotifyDevices.transferPlayback).toHaveBeenCalledWith('abc'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('MacBook Pro')); + logSpy.mockRestore(); + }); + + it('prints error when ID does not match any device', async () => { + vi.mocked(spotifyDevices.getDevices).mockResolvedValue(mockDevices); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runCommand(speakerCmd, { rawArgs: ['unknown-id'] }); + expect(spotifyDevices.transferPlayback).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('unknown-id')); + logSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/spotify/volume-down.ts b/packages/cli/src/commands/spotify/volume-down.ts index 5950b0e..976435a 100644 --- a/packages/cli/src/commands/spotify/volume-down.ts +++ b/packages/cli/src/commands/spotify/volume-down.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { volumeDown } from '@bitbard/core/spotify.js'; +import { volumeDown } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/commands/spotify/volume-up.ts b/packages/cli/src/commands/spotify/volume-up.ts index 113f3fc..50dbc2d 100644 --- a/packages/cli/src/commands/spotify/volume-up.ts +++ b/packages/cli/src/commands/spotify/volume-up.ts @@ -1,5 +1,5 @@ import { defineCommand } from 'citty'; -import { volumeUp } from '@bitbard/core/spotify.js'; +import { volumeUp } from '@bitbard/spotify/playback.js'; export default defineCommand({ meta: { diff --git a/packages/cli/src/plugins/macos.spec.ts b/packages/cli/src/plugins/macos.spec.ts index 8d4a0cc..d9e80fe 100644 --- a/packages/cli/src/plugins/macos.spec.ts +++ b/packages/cli/src/plugins/macos.spec.ts @@ -7,8 +7,11 @@ vi.mock('@bitbard/core/platform.js', () => ({ isMacOS: vi.fn().mockReturnValue(false), })); -vi.mock('@bitbard/core/spotify.js', () => ({ +vi.mock('@bitbard/spotify/app.js', () => ({ launch: vi.fn(), +})); + +vi.mock('@bitbard/spotify/playback.js', () => ({ play: vi.fn(), pause: vi.fn(), playpause: vi.fn(), @@ -34,6 +37,18 @@ vi.mock('@bitbard/core/daemon.js', () => ({ sendCommand: vi.fn().mockResolvedValue({ ok: true, result: 'ok' }), })); +vi.mock('@bitbard/spotify/auth.js', () => ({ + login: vi.fn(), + logout: vi.fn(), + isLoggedIn: vi.fn().mockResolvedValue(false), + getAccessToken: vi.fn(), +})); + +vi.mock('@bitbard/spotify/devices.js', () => ({ + getDevices: vi.fn().mockResolvedValue([]), + transferPlayback: vi.fn(), +})); + // Mock all clack/prompts symbols used by leaf commands so nothing blocks. vi.mock('@clack/prompts', () => ({ select: vi.fn().mockResolvedValue('light'), diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 9d0873d..1cda663 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,8 +4,8 @@ "noEmit": true, "paths": { "@bitbard/core/*.js": ["../core/src/*.ts"], - "@bitbard/core/pdf/*.js": ["../core/src/pdf/*.ts"] + "@bitbard/spotify/*.js": ["../spotify/src/*.ts"] } }, - "include": ["src/**/*.ts", "../core/src/**/*.ts"] + "include": ["src/**/*.ts"] } diff --git a/packages/spotify/package.json b/packages/spotify/package.json new file mode 100644 index 0000000..8b96b81 --- /dev/null +++ b/packages/spotify/package.json @@ -0,0 +1,23 @@ +{ + "name": "@bitbard/spotify", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@bitbard/core": "workspace:*", + "open": "catalog:" + }, + "devDependencies": { + "@bitbard/typescript-config": "workspace:*", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/spotify/src/app.spec.ts b/packages/spotify/src/app.spec.ts new file mode 100644 index 0000000..1e0c08c --- /dev/null +++ b/packages/spotify/src/app.spec.ts @@ -0,0 +1,15 @@ +vi.mock('@bitbard/core/applescript.js', () => ({ + runAppleScript: vi.fn(), +})); + +import { runAppleScript } from '@bitbard/core/applescript.js'; +import { launch } from './app.js'; + +beforeEach(() => vi.resetAllMocks()); + +describe('launch', () => { + it('activates Spotify', async () => { + await launch(); + expect(runAppleScript).toHaveBeenCalledWith('tell application "Spotify" to activate'); + }); +}); diff --git a/packages/spotify/src/app.ts b/packages/spotify/src/app.ts new file mode 100644 index 0000000..d6fe2ad --- /dev/null +++ b/packages/spotify/src/app.ts @@ -0,0 +1,7 @@ +import { runAppleScript } from '@bitbard/core/applescript.js'; + +const tell = (cmd: string): Promise => runAppleScript(`tell application "Spotify" to ${cmd}`); + +export async function launch(): Promise { + await tell('activate'); +} diff --git a/packages/spotify/src/auth.spec.ts b/packages/spotify/src/auth.spec.ts new file mode 100644 index 0000000..a8f1163 --- /dev/null +++ b/packages/spotify/src/auth.spec.ts @@ -0,0 +1,152 @@ +import * as keychain from '@bitbard/core/security/keychain.js'; +import { createServer } from 'node:http'; +import open from 'open'; +import { login, logout, getAccessToken, isLoggedIn } from './auth.js'; +import { SpotifyNotLoggedInError } from './errors.js'; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +vi.mock('node:http', () => ({ + createServer: vi.fn(), +})); + +// Mock open (browser launcher) — it's a default export +vi.mock('open', () => ({ default: vi.fn() })); + +vi.mock('@bitbard/core/security/keychain.js', () => ({ + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), +})); + +const SERVICE = 'bitbard-spotify'; + +beforeEach(() => vi.resetAllMocks()); + +describe('isLoggedIn', () => { + it('returns true when access_token exists in Keychain', async () => { + vi.mocked(keychain.get).mockResolvedValue('tok'); + expect(await isLoggedIn()).toBe(true); + expect(keychain.get).toHaveBeenCalledWith(SERVICE, 'access_token'); + }); + + it('returns false when access_token is not in Keychain', async () => { + vi.mocked(keychain.get).mockRejectedValue(new Error('not found')); + expect(await isLoggedIn()).toBe(false); + }); +}); + +describe('logout', () => { + it('deletes all four keychain entries', async () => { + vi.mocked(keychain.del).mockResolvedValue(undefined); + await logout(); + expect(keychain.del).toHaveBeenCalledWith(SERVICE, 'client_id'); + expect(keychain.del).toHaveBeenCalledWith(SERVICE, 'access_token'); + expect(keychain.del).toHaveBeenCalledWith(SERVICE, 'refresh_token'); + expect(keychain.del).toHaveBeenCalledWith(SERVICE, 'expires_at'); + }); + + it('does not throw if entries are already missing', async () => { + vi.mocked(keychain.del).mockRejectedValue(new Error('not found')); + await expect(logout()).resolves.not.toThrow(); + }); +}); + +describe('getAccessToken', () => { + it('returns token when not expired', async () => { + const expiresAt = String(Date.now() + 3600 * 1000); + + vi.mocked(keychain.get).mockImplementation((_service, key) => { + if (key === 'access_token') return Promise.resolve('my-access-token'); + if (key === 'expires_at') return Promise.resolve(expiresAt); + return Promise.reject(new Error('unexpected')); + }); + + const token = await getAccessToken(); + expect(token).toBe('my-access-token'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('refreshes and returns new token when expired', async () => { + const expired = String(Date.now() - 1000); + + vi.mocked(keychain.get).mockImplementation((_service, key) => { + if (key === 'access_token') return Promise.resolve('old-access-token'); + if (key === 'expires_at') return Promise.resolve(expired); + if (key === 'refresh_token') return Promise.resolve('my-refresh-token'); + if (key === 'client_id') return Promise.resolve('my-client-id'); + return Promise.reject(new Error('unexpected')); + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }), + }); + + const token = await getAccessToken(); + expect(token).toBe('new-access-token'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://accounts.spotify.com/api/token', + expect.objectContaining({ method: 'POST' }), + ); + expect(keychain.set).toHaveBeenCalledWith(SERVICE, 'access_token', 'new-access-token'); + expect(keychain.set).toHaveBeenCalledWith(SERVICE, 'refresh_token', 'new-refresh-token'); + }); + + it('throws SpotifyNotLoggedInError when not logged in', async () => { + vi.mocked(keychain.get).mockRejectedValue(new Error('not found')); + await expect(getAccessToken()).rejects.toThrow(SpotifyNotLoggedInError); + }); +}); + +describe('login', () => { + it('stores client_id, opens browser, exchanges code, stores tokens', async () => { + // Simulate HTTP server that immediately calls the callback handler with ?code=authcode + let capturedHandler: ((req: any, res: any) => void) | null = null; + const mockServer = { + listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()), + close: vi.fn(), + on: vi.fn(), + }; + vi.mocked(createServer).mockImplementation((handler: any) => { + capturedHandler = handler; + return mockServer as any; + }); + + // Simulate browser redirecting to callback after open() is called + vi.mocked(open).mockImplementation(async () => { + const req = { url: '/callback?code=authcode' }; + const res = { end: vi.fn() }; + capturedHandler!(req, res); + return {} as any; + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'acc', + refresh_token: 'ref', + expires_in: 3600, + }), + }); + vi.mocked(keychain.set).mockResolvedValue(undefined); + + await login('my-client-id'); + + expect(open).toHaveBeenCalledWith(expect.stringContaining('my-client-id')); + expect(open).toHaveBeenCalledWith(expect.stringContaining('code_challenge')); + expect(mockFetch).toHaveBeenCalledWith( + 'https://accounts.spotify.com/api/token', + expect.objectContaining({ method: 'POST' }), + ); + expect(keychain.set).toHaveBeenCalledWith(SERVICE, 'client_id', 'my-client-id'); + expect(keychain.set).toHaveBeenCalledWith(SERVICE, 'access_token', 'acc'); + expect(keychain.set).toHaveBeenCalledWith(SERVICE, 'refresh_token', 'ref'); + expect(keychain.set).toHaveBeenCalledWith(SERVICE, 'expires_at', expect.any(String)); + }); +}); diff --git a/packages/spotify/src/auth.ts b/packages/spotify/src/auth.ts new file mode 100644 index 0000000..a2ad22d --- /dev/null +++ b/packages/spotify/src/auth.ts @@ -0,0 +1,172 @@ +import { createServer } from 'node:http'; +import { createHash, randomBytes } from 'node:crypto'; +import open from 'open'; +import { get, set, del } from '@bitbard/core/security/keychain.js'; +import { SpotifyNotLoggedInError, SpotifySessionExpiredError } from './errors.js'; + +const SERVICE = 'bitbard-spotify'; +const REDIRECT_URI = 'http://127.0.0.1:8888/callback'; +const SCOPES = 'user-read-playback-state user-modify-playback-state'; +const TOKEN_URL = 'https://accounts.spotify.com/api/token'; +const AUTH_URL = 'https://accounts.spotify.com/authorize'; +// Refresh if token expires within 60 seconds +const EXPIRY_BUFFER_MS = 60 * 1000; + +function generateCodeVerifier(): string { + return randomBytes(96).toString('base64url').slice(0, 128); +} + +function generateCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url'); +} + +function waitForCode(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://127.0.0.1:8888`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + res.end('You can close this tab.'); + res.socket?.destroy(); + server.close(); + if (error) reject(new Error(`Spotify auth error: ${error}`)); + else if (code) resolve(code); + else reject(new Error('No code received from Spotify')); + }); + server.listen(8888, '127.0.0.1', () => {}); + server.on('error', (err) => reject(err)); + }); +} + +async function exchangeCode( + code: string, + codeVerifier: string, + clientId: string, +): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }> { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + code_verifier: codeVerifier, + }); + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Token exchange failed ${res.status}: ${text}`); + } + const data = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +async function refreshTokens( + refreshToken: string, + clientId: string, +): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }> { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + }); + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Token refresh failed ${res.status}: ${text}`); + } + const data = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +export async function login(clientId: string): Promise { + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + redirect_uri: REDIRECT_URI, + code_challenge_method: 'S256', + code_challenge: challenge, + scope: SCOPES, + }); + + const codePromise = waitForCode(); + await open(`${AUTH_URL}?${params}`); + const code = await codePromise; + + const { accessToken, refreshToken, expiresAt } = await exchangeCode(code, verifier, clientId); + + await Promise.allSettled([ + set(SERVICE, 'client_id', clientId), + set(SERVICE, 'access_token', accessToken), + set(SERVICE, 'refresh_token', refreshToken), + set(SERVICE, 'expires_at', String(expiresAt)), + ]); +} + +export async function logout(): Promise { + await Promise.allSettled([ + del(SERVICE, 'client_id'), + del(SERVICE, 'access_token'), + del(SERVICE, 'refresh_token'), + del(SERVICE, 'expires_at'), + ]); +} + +export async function getAccessToken(): Promise { + let accessToken: string; + let expiresAt: number; + + try { + [accessToken, expiresAt] = await Promise.all([ + get(SERVICE, 'access_token'), + get(SERVICE, 'expires_at').then(Number), + ]); + } catch { + throw new SpotifyNotLoggedInError(); + } + + if (Date.now() + EXPIRY_BUFFER_MS >= expiresAt) { + let refreshToken: string; + let clientId: string; + + try { + [refreshToken, clientId] = await Promise.all([get(SERVICE, 'refresh_token'), get(SERVICE, 'client_id')]); + } catch { + throw new SpotifySessionExpiredError(); + } + + const tokens = await refreshTokens(refreshToken, clientId); + await set(SERVICE, 'access_token', tokens.accessToken); + await set(SERVICE, 'refresh_token', tokens.refreshToken); + await set(SERVICE, 'expires_at', String(tokens.expiresAt)); + return tokens.accessToken; + } + + return accessToken; +} + +export async function isLoggedIn(): Promise { + try { + await get(SERVICE, 'access_token'); + return true; + } catch { + return false; + } +} diff --git a/packages/spotify/src/devices.spec.ts b/packages/spotify/src/devices.spec.ts new file mode 100644 index 0000000..11d73b7 --- /dev/null +++ b/packages/spotify/src/devices.spec.ts @@ -0,0 +1,70 @@ +vi.mock('./auth.js', () => ({ + getAccessToken: vi.fn(), +})); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +import { getAccessToken } from './auth.js'; +import { getDevices, transferPlayback } from './devices.js'; + +beforeEach(() => vi.resetAllMocks()); + +describe('getDevices', () => { + it('returns parsed device list', async () => { + vi.mocked(getAccessToken).mockResolvedValue('tok'); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + devices: [ + { id: 'abc', name: 'MacBook Pro', type: 'Computer', is_active: true, volume_percent: 80 }, + { id: 'def', name: 'Living Room', type: 'Speaker', is_active: false, volume_percent: null }, + ], + }), + }); + + const devices = await getDevices(); + expect(devices).toEqual([ + { id: 'abc', name: 'MacBook Pro', type: 'Computer', isActive: true, volumePercent: 80 }, + { id: 'def', name: 'Living Room', type: 'Speaker', isActive: false, volumePercent: null }, + ]); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.spotify.com/v1/me/player/devices', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer tok' }) }), + ); + }); + + it('returns empty array when no devices', async () => { + vi.mocked(getAccessToken).mockResolvedValue('tok'); + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ devices: [] }) }); + expect(await getDevices()).toEqual([]); + }); + + it('throws on non-2xx response', async () => { + vi.mocked(getAccessToken).mockResolvedValue('tok'); + mockFetch.mockResolvedValue({ ok: false, status: 401, text: async () => 'Unauthorized' }); + await expect(getDevices()).rejects.toThrow('401'); + }); +}); + +describe('transferPlayback', () => { + it('calls PUT /v1/me/player with device_id', async () => { + vi.mocked(getAccessToken).mockResolvedValue('tok'); + mockFetch.mockResolvedValue({ ok: true }); + + await transferPlayback('abc'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.spotify.com/v1/me/player', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ device_ids: ['abc'] }), + }), + ); + }); + + it('throws on non-2xx response', async () => { + vi.mocked(getAccessToken).mockResolvedValue('tok'); + mockFetch.mockResolvedValue({ ok: false, status: 403, text: async () => 'Forbidden' }); + await expect(transferPlayback('abc')).rejects.toThrow('403'); + }); +}); diff --git a/packages/spotify/src/devices.ts b/packages/spotify/src/devices.ts new file mode 100644 index 0000000..4da94d6 --- /dev/null +++ b/packages/spotify/src/devices.ts @@ -0,0 +1,54 @@ +import { getAccessToken } from './auth.js'; + +const API = 'https://api.spotify.com/v1'; + +export interface SpotifyDevice { + id: string; + name: string; + type: string; + isActive: boolean; + volumePercent: number | null; +} + +export async function getDevices(): Promise { + const token = await getAccessToken(); + const res = await fetch(`${API}/me/player/devices`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Spotify API error ${res.status}: ${text}`); + } + const data = (await res.json()) as { + devices: Array<{ + id: string; + name: string; + type: string; + is_active: boolean; + volume_percent: number | null; + }>; + }; + return data.devices.map((d) => ({ + id: d.id, + name: d.name, + type: d.type, + isActive: d.is_active, + volumePercent: d.volume_percent, + })); +} + +export async function transferPlayback(deviceId: string): Promise { + const token = await getAccessToken(); + const res = await fetch(`${API}/me/player`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ device_ids: [deviceId] }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Spotify API error ${res.status}: ${text}`); + } +} diff --git a/packages/spotify/src/errors.ts b/packages/spotify/src/errors.ts new file mode 100644 index 0000000..3b98a9d --- /dev/null +++ b/packages/spotify/src/errors.ts @@ -0,0 +1,13 @@ +export class SpotifyNotLoggedInError extends Error { + constructor() { + super('Not logged in to Spotify'); + this.name = 'SpotifyNotLoggedInError'; + } +} + +export class SpotifySessionExpiredError extends Error { + constructor() { + super('Spotify session expired'); + this.name = 'SpotifySessionExpiredError'; + } +} diff --git a/packages/core/src/spotify.spec.ts b/packages/spotify/src/playback.spec.ts similarity index 82% rename from packages/core/src/spotify.spec.ts rename to packages/spotify/src/playback.spec.ts index 0b14028..e246df7 100644 --- a/packages/core/src/spotify.spec.ts +++ b/packages/spotify/src/playback.spec.ts @@ -1,22 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('./applescript.js', () => ({ +vi.mock('@bitbard/core/applescript.js', () => ({ runAppleScript: vi.fn(), })); -import { runAppleScript } from './applescript.js'; -import { launch, playpause, previousTrack, nextTrack, getNowPlaying, volumeUp, volumeDown } from './spotify.js'; +import { runAppleScript } from '@bitbard/core/applescript.js'; +import { playpause, previousTrack, nextTrack, getNowPlaying, volumeUp, volumeDown } from './playback.js'; beforeEach(() => vi.resetAllMocks()); -describe('launch', () => { - it('activates Spotify', async () => { - vi.mocked(runAppleScript).mockResolvedValue(''); - await launch(); - expect(runAppleScript).toHaveBeenCalledWith('tell application "Spotify" to activate'); - }); -}); - describe('playpause', () => { it('sends playpause', async () => { vi.mocked(runAppleScript).mockResolvedValue(''); diff --git a/packages/core/src/spotify.ts b/packages/spotify/src/playback.ts similarity index 90% rename from packages/core/src/spotify.ts rename to packages/spotify/src/playback.ts index 5b9056a..2769402 100644 --- a/packages/core/src/spotify.ts +++ b/packages/spotify/src/playback.ts @@ -1,11 +1,7 @@ -import { runAppleScript } from './applescript.js'; +import { runAppleScript } from '@bitbard/core/applescript.js'; const tell = (cmd: string): Promise => runAppleScript(`tell application "Spotify" to ${cmd}`); -export async function launch(): Promise { - await runAppleScript('tell application "Spotify" to activate'); -} - export async function play(): Promise { await tell('play'); } diff --git a/packages/spotify/tsconfig.json b/packages/spotify/tsconfig.json new file mode 100644 index 0000000..a5287d8 --- /dev/null +++ b/packages/spotify/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@bitbard/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@bitbard/core/*.js": ["../core/src/*.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/spotify/vitest.config.ts b/packages/spotify/vitest.config.ts new file mode 100644 index 0000000..014f97e --- /dev/null +++ b/packages/spotify/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +}); diff --git a/packages/typescript-config/base.json b/packages/typescript-config/base.json index 7760d24..ba4bb2b 100644 --- a/packages/typescript-config/base.json +++ b/packages/typescript-config/base.json @@ -7,6 +7,6 @@ "strict": true, "skipLibCheck": true, "esModuleInterop": true, - "types": ["node"] + "types": ["node", "vitest/globals"] } }