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 +4 -4
- data/CHANGELOG.md +14 -0
- data/exe/pandoru-login +44 -0
- data/lib/pandoru/credentials.rb +109 -0
- data/lib/pandoru/secret_store.rb +173 -0
- data/lib/pandoru/version.rb +1 -1
- data/lib/pandoru.rb +2 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd2045e11084de310e45882480a3e8472ebceac1e81ca927682f978f10458ffa
|
|
4
|
+
data.tar.gz: f279f57d936e055be8c819f154a9b863c45ffdf01ceb4970cd130035c480b033
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/pandoru/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dale Stevens
|
|
8
|
-
bindir:
|
|
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
|