pandoru 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad02207b38cdbe7ac38fe971c4a3c13e1f439336791f559b6c3bd6b2cc38ef36
4
- data.tar.gz: da0f8c0372e9302bfee89f5ddcd7d1b460265f3f55b21ef1054c1bc59e8c6b2e
3
+ metadata.gz: fd2045e11084de310e45882480a3e8472ebceac1e81ca927682f978f10458ffa
4
+ data.tar.gz: f279f57d936e055be8c819f154a9b863c45ffdf01ceb4970cd130035c480b033
5
5
  SHA512:
6
- metadata.gz: ff0b263da1d80d461ccdf1e81277fb5c22f3fb55f0651b89a317f69f47970228297bb03a6acf6d13bea91567ea7f86f56577db3a52cd991b55f1ee0df8360843
7
- data.tar.gz: a419c0670a1ce0d57ac5fece5c7b194d67292a4cd2a648b494a6df5b59fdcf7dee7ea23a267746d9c01292e2b0dcabfb75e5d524853907e2eda42b95d0192aff
6
+ metadata.gz: 654f2fcb4948e93307ce68650c7b53fbd29e20252dac628311e5509ec3b25bfb2b0f9116d51ed5da583cc72fecd91333b2518ea206117ef1418fd7a9eec05453
7
+ data.tar.gz: aed635c1e7eec1bc31b7790386f99c700d4964061dcc672535d9233e1ee81381050687d8829e37709ba491cfac190557f842a5cb42e6022eacf04728378a1b4f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to this project are documented here. The format is based
4
4
  on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] - 2026-05-25
8
+
9
+ ### Added
10
+ - `Pandoru::Credentials.resolve` — tiered credential resolution: explicit args →
11
+ `PANDORA_USERNAME`/`PANDORA_PASSWORD` env → OS secret store → JSON file at
12
+ `$PANDORU_CREDENTIALS` / `~/.config/pandoru/credentials.json` (first complete
13
+ pair wins; blank values fall through).
14
+ - `Pandoru::SecretStore` — portable OS secret storage (macOS Keychain, Linux
15
+ libsecret; Windows falls back to the file tier) so the password need not live
16
+ in plaintext.
17
+ - `pandoru-login` executable — prompt for credentials and store them in the OS
18
+ secret store.
19
+
7
20
  ## [0.1.1] - 2026-05-25
8
21
 
9
22
  ### Fixed
@@ -40,5 +53,6 @@ targeting Pandora's partner/device JSON API (`tuner.pandora.com/services/json/`)
40
53
  - Corrected the encryption/decryption key orientation in the bundled default
41
54
  partner settings.
42
55
 
56
+ [0.2.0]: https://github.com/TwilightCoders/pandoru/releases/tag/v0.2.0
43
57
  [0.1.1]: https://github.com/TwilightCoders/pandoru/releases/tag/v0.1.1
44
58
  [0.1.0]: https://github.com/TwilightCoders/pandoru/releases/tag/v0.1.0
data/exe/pandoru-login ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Store Pandora credentials in the OS secret store (macOS Keychain, Linux
5
+ # libsecret) so no password lives in a plaintext file. The client reads them at
6
+ # runtime via Pandoru::Credentials.
7
+ #
8
+ # pandoru-login # prompt + store
9
+ # pandoru-login --delete # remove the stored item
10
+
11
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
12
+
13
+ require 'pandoru'
14
+ require 'io/console'
15
+
16
+ store = Pandoru::SecretStore
17
+
18
+ unless store.available?
19
+ warn "No OS secret store available on this platform (#{store.backend_name})."
20
+ warn 'Fall back to a credentials file — see README "Credentials".'
21
+ exit 1
22
+ end
23
+
24
+ if ARGV[0] == '--delete'
25
+ removed = store.delete
26
+ puts removed ? 'Removed Pandora credentials from the secret store.' : 'Nothing stored.'
27
+ exit removed ? 0 : 1
28
+ end
29
+
30
+ print 'Pandora email/username: '
31
+ username = $stdin.gets&.strip
32
+ abort 'Aborted — no username.' if username.nil? || username.empty?
33
+
34
+ print 'Password (hidden): '
35
+ password = $stdin.noecho(&:gets)&.chomp
36
+ puts
37
+ abort 'Aborted — no password.' if password.nil? || password.empty?
38
+
39
+ if store.store(username, password)
40
+ puts "✓ Stored in #{store.backend_name} (account: #{username})."
41
+ puts 'No password on disk — the client reads it at runtime.'
42
+ else
43
+ abort 'Failed to store credentials in the secret store.'
44
+ end
@@ -0,0 +1,109 @@
1
+ require 'json'
2
+
3
+ module Pandoru
4
+ # Resolves Pandora account credentials from the first source that supplies a
5
+ # complete pair, so the client/CLI works whether you prefer env vars, the OS
6
+ # secret store, or a config file. Empty values count as absent, so an
7
+ # unset/blank env var gracefully falls through to the next source rather than
8
+ # half-authenticating.
9
+ #
10
+ # Precedence (highest first), mirroring the common config-resolution standard
11
+ # (explicit wins; env for 12-factor/CI; XDG Base Directory for the file):
12
+ #
13
+ # 1. Explicit values passed in (tests, embedding).
14
+ # 2. ENV PANDORA_USERNAME / PANDORA_PASSWORD.
15
+ # 3. The OS secret store (Keychain / libsecret), if a credential is stored.
16
+ # 4. A JSON file { "username": ..., "password": ... } at the first that
17
+ # exists, in order:
18
+ # $PANDORU_CREDENTIALS (explicit path override)
19
+ # $XDG_CONFIG_HOME/pandoru/credentials.json (XDG; default ~/.config)
20
+ #
21
+ # A future tier should delegate to Pandoru::ClientBuilders for the existing
22
+ # pydora `.cfg` / pianobar config files rather than reimplementing that
23
+ # parsing — tracked as a follow-up.
24
+ class Credentials
25
+ class NotFound < StandardError; end
26
+
27
+ Resolved = Struct.new(:username, :password, :source, keyword_init: true)
28
+
29
+ USERNAME_KEYS = %w[username user email].freeze
30
+ PASSWORD_KEYS = %w[password pass].freeze
31
+
32
+ def self.resolve(username: nil, password: nil, env: ENV, home: Dir.home, secret_store: SecretStore)
33
+ new(env: env, home: home, secret_store: secret_store)
34
+ .resolve(username: username, password: password)
35
+ end
36
+
37
+ def initialize(env: ENV, home: Dir.home, secret_store: SecretStore)
38
+ @env = env
39
+ @home = home
40
+ @secret_store = secret_store
41
+ end
42
+
43
+ def resolve(username: nil, password: nil)
44
+ if present?(username) && present?(password)
45
+ return Resolved.new(username: username, password: password, source: :explicit)
46
+ end
47
+
48
+ if present?(@env['PANDORA_USERNAME']) && present?(@env['PANDORA_PASSWORD'])
49
+ return Resolved.new(username: @env['PANDORA_USERNAME'],
50
+ password: @env['PANDORA_PASSWORD'], source: :env)
51
+ end
52
+
53
+ if @secret_store && (creds = @secret_store.fetch)
54
+ return Resolved.new(username: creds[0], password: creds[1], source: :secret_store)
55
+ end
56
+
57
+ candidate_files.each do |path|
58
+ next unless File.file?(path)
59
+ creds = parse_file(path)
60
+ next unless creds
61
+ return Resolved.new(username: creds[0], password: creds[1], source: path)
62
+ end
63
+
64
+ raise NotFound, not_found_message
65
+ end
66
+
67
+ # Ordered list of JSON credential paths to try.
68
+ def candidate_files
69
+ paths = []
70
+ paths << @env['PANDORU_CREDENTIALS'] if present?(@env['PANDORU_CREDENTIALS'])
71
+ paths << File.join(xdg_config_home, 'pandoru', 'credentials.json')
72
+ paths.uniq
73
+ end
74
+
75
+ private
76
+
77
+ def xdg_config_home
78
+ present?(@env['XDG_CONFIG_HOME']) ? @env['XDG_CONFIG_HOME'] : File.join(@home, '.config')
79
+ end
80
+
81
+ # Returns [username, password] from a JSON file, or nil if unreadable or
82
+ # incomplete (so resolution continues to the next candidate).
83
+ def parse_file(path)
84
+ data = JSON.parse(File.read(path))
85
+ return nil unless data.is_a?(Hash)
86
+
87
+ username = USERNAME_KEYS.map { |k| data[k] }.find { |v| present?(v) }
88
+ password = PASSWORD_KEYS.map { |k| data[k] }.find { |v| present?(v) }
89
+ present?(username) && present?(password) ? [username, password] : nil
90
+ rescue JSON::ParserError
91
+ nil
92
+ end
93
+
94
+ def present?(value)
95
+ !value.nil? && !value.to_s.strip.empty?
96
+ end
97
+
98
+ def not_found_message
99
+ checked = ['PANDORA_USERNAME / PANDORA_PASSWORD (env)', 'OS secret store (run pandoru-login)'] + candidate_files
100
+ <<~MSG.strip
101
+ No Pandora credentials found. Checked, in order:
102
+ #{checked.map { |c| " - #{c}" }.join("\n")}
103
+ Store them with `pandoru-login`, provide a JSON file like
104
+ { "username": "you@example.com", "password": "…" }, or set the
105
+ PANDORA_USERNAME / PANDORA_PASSWORD environment variables.
106
+ MSG
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,173 @@
1
+ require 'open3'
2
+ require 'json'
3
+
4
+ module Pandoru
5
+ # Portable secret storage: keeps the Pandora credential out of any plaintext
6
+ # file by delegating to the host OS's native secret service. There's no solid
7
+ # cross-platform Ruby gem for this, so we shell out to each platform's tool,
8
+ # the way fastlane et al. do:
9
+ #
10
+ # macOS → security (Keychain)
11
+ # Linux → secret-tool (libsecret / Secret Service: GNOME Keyring, KWallet)
12
+ # Windows → Credential Manager (not yet wired — see Adapters::Windows)
13
+ #
14
+ # The credential is stored as a single JSON blob { "username", "password" }
15
+ # under one fixed key, so retrieval is uniform across backends and we never
16
+ # need to enumerate the store to discover the username. Everything degrades
17
+ # gracefully: with no working backend, fetch returns nil and the resolver
18
+ # falls through to the config file.
19
+ module SecretStore
20
+ SERVICE = 'pandoru'
21
+
22
+ # Shells out, returning [stdout, success?]. Injectable so adapters can be
23
+ # unit-tested without touching a real keychain.
24
+ DEFAULT_RUNNER = lambda do |cmd, stdin_data|
25
+ opts = { err: File::NULL }
26
+ opts[:stdin_data] = stdin_data if stdin_data
27
+ out, status = Open3.capture2(*cmd, **opts)
28
+ [out, status.success?]
29
+ rescue Errno::ENOENT
30
+ ['', false]
31
+ end
32
+
33
+ module_function
34
+
35
+ # The first available adapter for this host, or a Null adapter.
36
+ def adapter(runner: DEFAULT_RUNNER)
37
+ [Adapters::MacOS, Adapters::SecretTool, Adapters::Windows]
38
+ .map { |klass| klass.new(runner: runner) }
39
+ .find(&:available?) || Adapters::Null.new(runner: runner)
40
+ end
41
+
42
+ def available?(adapter: adapter())
43
+ !adapter.is_a?(Adapters::Null)
44
+ end
45
+
46
+ def backend_name(adapter: adapter())
47
+ adapter.name
48
+ end
49
+
50
+ # [username, password] from the store, or nil if absent/unreadable.
51
+ def fetch(service: SERVICE, adapter: adapter())
52
+ raw = adapter.read(service)
53
+ return nil if raw.nil? || raw.strip.empty?
54
+
55
+ data = JSON.parse(raw)
56
+ username = data['username']
57
+ password = data['password']
58
+ return nil unless present?(username) && present?(password)
59
+
60
+ [username, password]
61
+ rescue JSON::ParserError
62
+ nil
63
+ end
64
+
65
+ def store(username, password, service: SERVICE, adapter: adapter())
66
+ adapter.write(service, JSON.generate('username' => username, 'password' => password))
67
+ end
68
+
69
+ def delete(service: SERVICE, adapter: adapter())
70
+ adapter.delete(service)
71
+ end
72
+
73
+ def present?(value)
74
+ !value.nil? && !value.to_s.strip.empty?
75
+ end
76
+
77
+ # Per-OS adapters. Each maps the generic read/write/delete of an opaque
78
+ # secret string to the native CLI; all I/O goes through the injected runner.
79
+ module Adapters
80
+ class Base
81
+ def initialize(runner: DEFAULT_RUNNER)
82
+ @runner = runner
83
+ end
84
+
85
+ def name = self.class.name.split('::').last
86
+
87
+ private
88
+
89
+ def run(cmd, stdin_data: nil)
90
+ @runner.call(cmd, stdin_data)
91
+ end
92
+
93
+ def which?(tool)
94
+ _out, ok = run(['which', tool])
95
+ ok
96
+ end
97
+ end
98
+
99
+ # macOS Keychain via `security`. Account is fixed to the service name; the
100
+ # secret blob is the password slot.
101
+ class MacOS < Base
102
+ def name = 'macOS Keychain'
103
+
104
+ def available?
105
+ RUBY_PLATFORM.include?('darwin') && which?('security')
106
+ end
107
+
108
+ def read(service)
109
+ out, ok = run(['security', 'find-generic-password', '-s', service, '-w'])
110
+ ok ? out.chomp : nil
111
+ end
112
+
113
+ def write(service, secret)
114
+ _out, ok = run(['security', 'add-generic-password', '-U',
115
+ '-s', service, '-a', service, '-w', secret,
116
+ '-D', 'application password', '-l', service])
117
+ ok
118
+ end
119
+
120
+ def delete(service)
121
+ _out, ok = run(['security', 'delete-generic-password', '-s', service])
122
+ ok
123
+ end
124
+ end
125
+
126
+ # Linux libsecret via `secret-tool` (Secret Service: GNOME Keyring/KWallet).
127
+ class SecretTool < Base
128
+ def name = 'libsecret (secret-tool)'
129
+
130
+ def available?
131
+ RUBY_PLATFORM.include?('linux') && which?('secret-tool')
132
+ end
133
+
134
+ def read(service)
135
+ out, ok = run(['secret-tool', 'lookup', 'service', service])
136
+ ok ? out.chomp : nil
137
+ end
138
+
139
+ def write(service, secret)
140
+ _out, ok = run(['secret-tool', 'store', '--label', service, 'service', service],
141
+ stdin_data: secret)
142
+ ok
143
+ end
144
+
145
+ def delete(service)
146
+ _out, ok = run(['secret-tool', 'clear', 'service', service])
147
+ ok
148
+ end
149
+ end
150
+
151
+ # Windows Credential Manager. Left unwired: doing it without a gem means a
152
+ # PowerShell + Win32 CredRead/CredWrite P/Invoke shim, which can't be
153
+ # verified here. Reports unavailable so Windows falls back to the config
154
+ # file. Implement read/write/delete against `cmdkey`/PowerShell to enable.
155
+ class Windows < Base
156
+ def name = 'Windows Credential Manager (unsupported)'
157
+ def available? = false
158
+ def read(_service) = nil
159
+ def write(_service, _secret) = false
160
+ def delete(_service) = false
161
+ end
162
+
163
+ # No native store available — everything no-ops, resolver uses the file.
164
+ class Null < Base
165
+ def name = 'none'
166
+ def available? = false
167
+ def read(_service) = nil
168
+ def write(_service, _secret) = false
169
+ def delete(_service) = false
170
+ end
171
+ end
172
+ end
173
+ end
@@ -1,3 +1,3 @@
1
1
  module Pandoru
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/pandoru.rb CHANGED
@@ -4,6 +4,8 @@ require_relative 'pandoru/transport'
4
4
  require_relative 'pandoru/client'
5
5
  require_relative 'pandoru/client_builder'
6
6
  require_relative 'pandoru/models'
7
+ require_relative 'pandoru/secret_store'
8
+ require_relative 'pandoru/credentials'
7
9
  require 'pathname'
8
10
  require 'logger'
9
11
 
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandoru
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dale Stevens
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -195,16 +195,19 @@ description: A comprehensive Ruby client for the Pandora music streaming API, pr
195
195
  access to stations, playlists, search, and user management features.
196
196
  email:
197
197
  - dale@twilightcoders.net
198
- executables: []
198
+ executables:
199
+ - pandoru-login
199
200
  extensions: []
200
201
  extra_rdoc_files: []
201
202
  files:
202
203
  - CHANGELOG.md
203
204
  - LICENSE
204
205
  - README.md
206
+ - exe/pandoru-login
205
207
  - lib/pandoru.rb
206
208
  - lib/pandoru/client.rb
207
209
  - lib/pandoru/client_builder.rb
210
+ - lib/pandoru/credentials.rb
208
211
  - lib/pandoru/errors.rb
209
212
  - lib/pandoru/models.rb
210
213
  - lib/pandoru/models/_base.rb
@@ -213,6 +216,7 @@ files:
213
216
  - lib/pandoru/models/search.rb
214
217
  - lib/pandoru/models/station.rb
215
218
  - lib/pandoru/models/track_explanation.rb
219
+ - lib/pandoru/secret_store.rb
216
220
  - lib/pandoru/transport.rb
217
221
  - lib/pandoru/version.rb
218
222
  homepage: https://github.com/TwilightCoders/pandoru