wenmar 0.8.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 43545722c16579d457083b3ca7c8ce6d5d29c04810f409009aef3d54f4a46cd4
4
+ data.tar.gz: 91d256168a7a1dac6ab545420a3567d28e2bc1a1a99c1dc9d660e33b013aca98
5
+ SHA512:
6
+ metadata.gz: e433cfa494a87297b498ad3f9586800eb1dabc37b241caafc24f552232d411ad9e1e15104c6a8d5f184ddc2e9c01589671afe4d05fc17f0e544353d8503649f1
7
+ data.tar.gz: f6cb3a1c6187e884a9ef94774ef25873d8351680fe0522c057802f405836c91095e4170e6611a838e9e141bc0783eb57c450a79af2607e722ad991aba66230a7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wenmar Pro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # wenmar — Ruby SDK
2
+
3
+ Ruby SDK for the Wenmar Pro API.
4
+
5
+ ## Installation
6
+
7
+ Add to your `Gemfile`:
8
+
9
+ ```ruby
10
+ gem "wenmar"
11
+ ```
12
+
13
+ then run `bundle install`. Or install it directly:
14
+
15
+ ```sh
16
+ gem install wenmar
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```ruby
22
+ require "wenmar"
23
+
24
+ client = Wenmar::Client.new(token: "YOUR_API_TOKEN")
25
+
26
+ # List customers (paginated)
27
+ customers = client.list_customers
28
+ customers # => [{ "id" => 1, "full_name" => "Jane Doe", ... }]
29
+
30
+ # Show a customer
31
+ customer = client.show_customer(1)
32
+ customer # => { "id" => 1, "full_name" => "Jane Doe", ... }
33
+
34
+ # Create a customer (request body is nested under the resource key)
35
+ created = client.create_customer(customer: { first_name: "Jane", last_name: "Doe" })
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ `Wenmar::Client.new` takes a token (required) and an optional base URL:
41
+
42
+ ```ruby
43
+ client = Wenmar::Client.new(token: "YOUR_API_KEY", base_url: "https://app.wenmarpro.com")
44
+ ```
45
+
46
+ ## Location scoping
47
+
48
+ Use `for_location` to scope every request to a specific location. The parent
49
+ client is not mutated:
50
+
51
+ ```ruby
52
+ shop = client.for_location("42")
53
+ shop.list_customers # sends X-Wenmar-Location: 42
54
+ ```
55
+
56
+ ## API coverage
57
+
58
+ The full surface is generated into `resources.rb` — hundreds of methods across
59
+ every tag. See the [generated API reference](../docs/api/api-reference.md) for
60
+ the live list. A representative sample:
61
+
62
+ | Operation | Method |
63
+ |---|---|
64
+ | List customers | `list_customers(customer_tag_id:, has_balance:, has_vehicle:, last_visit_months:, page:, per_page:, q:, status:, type:)` |
65
+ | Create customer | `create_customer(customer:)` |
66
+ | Show customer | `show_customer(id)` |
67
+ | Update customer | `update_customer(id, customer:)` |
68
+ | List vehicles | `list_vehicles(page:, per_page:, q:, ...)` |
69
+ | Create vehicle | `create_vehicle(vehicle:)` |
70
+ | Show vehicle | `show_vehicle(id)` |
71
+ | Update vehicle | `update_vehicle(id, vehicle:)` |
72
+ | Trash vehicle | `trash_vehicle(id)` |
73
+ | Decode VIN | `decode_vin(vin:)` |
74
+ | Check duplicates | `check_vehicle_duplicate(vin:)` |
75
+ | List work orders | `list_work_orders(page:, per_page:, q:, ...)` |
76
+ | Create work order | `create_work_order(work_order:)` |
77
+ | Show work order | `show_work_order(id)` |
78
+ | Update work order | `update_work_order(id, work_order:)` |
79
+ | Void work order | `void_work_order(id, closure_reason:)` |
80
+ | Reopen work order | `reopen_work_order(id)` |
81
+
82
+ Work orders use a domain workflow (`stage`) and are never hard-deleted — use
83
+ `void_work_order`/`reopen_work_order`. Lifecycle-managed resources (customers,
84
+ vehicles, vendors, …) support `trash_*`/`archive_*`/`restore_*` rather than
85
+ `delete`.
86
+
87
+ Every paginated list also has a `get_all_*` variant that auto-paginates with a
88
+ 1,000-item safety cap, e.g. `get_all_customers`.
89
+
90
+ ## Pagination
91
+
92
+ List endpoints paginate via the RFC 5988 `Link` header. Paginated list methods
93
+ return a `Wenmar::Paginator`:
94
+
95
+ ```ruby
96
+ result = client.list_customers
97
+ result.each { |customer| puts customer["full_name"] }
98
+ ```
99
+
100
+ Or collect everything with `get_all_customers`.
101
+
102
+ ## Errors
103
+
104
+ All non-2xx responses raise `Wenmar::Error`:
105
+
106
+ ```ruby
107
+ begin
108
+ client.show_customer(999)
109
+ rescue Wenmar::Error => e
110
+ e.code # => "not_found"
111
+ e.status # => 404
112
+ e.message
113
+ e.field_errors
114
+ end
115
+ ```
116
+
117
+ See [docs/errors.md](../docs/api/errors.md) for the full error envelope and code table.
118
+
119
+ ## Retry
120
+
121
+ The client retries 429/503/504 with exponential backoff (max 3 retries). It
122
+ respects the `Retry-After` response header. Mutations are only retried on 429 —
123
+ never on transport errors, which could duplicate a write.
124
+
125
+ ## OAuth and credential storage
126
+
127
+ For OAuth (the browser `authorization_code` + PKCE flow is a CLI concern; the
128
+ SDK ships the token model, store, and refresh machinery):
129
+
130
+ ```ruby
131
+ require "wenmar"
132
+
133
+ # Exchange a refresh token for a new access token.
134
+ token = Wenmar::OAuth.refresh(
135
+ base_url: "https://app.wenmarpro.com",
136
+ refresh_token: refresh_token
137
+ )
138
+
139
+ # Persist the full token (access + refresh + expiry) with 0600 permissions.
140
+ store = Wenmar::CredentialStore.new
141
+ store.save_token(token)
142
+
143
+ # Auto-refresh when the token is expired or within 5 minutes of expiry.
144
+ manager = Wenmar::AuthManager.new(store: store, oauth: { base_url: "https://app.wenmarpro.com" })
145
+ provider = Wenmar::CredentialStoreProvider.new(store: store, manager: manager)
146
+ client = Wenmar::Client.new(token_provider: provider)
147
+ ```
148
+
149
+ `Wenmar::KeychainStore` is an alternative macOS keychain-backed store (it
150
+ requires the optional `ruby-keychain` gem, which is **not** a runtime
151
+ dependency — install it yourself if you want keychain storage).
152
+
153
+ ## Documentation
154
+
155
+ - [API reference](../docs/api/api-reference.md)
156
+ - [Authentication](../docs/api/authentication.md)
157
+ - [Pagination](../docs/api/pagination.md)
158
+ - [Errors](../docs/api/errors.md)
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wenmar
4
+ class TokenError < StandardError; end
5
+
6
+ module TokenProvider
7
+ def token
8
+ raise NotImplementedError
9
+ end
10
+ end
11
+
12
+ class StaticTokenProvider
13
+ include TokenProvider
14
+
15
+ def initialize(token)
16
+ raise ArgumentError, "token is required" if token.nil? || token.empty?
17
+
18
+ @token = token
19
+ end
20
+
21
+ attr_reader :token
22
+ end
23
+
24
+ # Reads a Wenmar::Token from a store, auto-refreshing via an AuthManager when
25
+ # the stored token is expired or within a refresh window of expiry.
26
+ class CredentialStoreProvider
27
+ DEFAULT_REFRESH_WINDOW = 300
28
+
29
+ def initialize(store:, manager: nil, refresh_window: DEFAULT_REFRESH_WINDOW)
30
+ @store = store
31
+ @manager = manager
32
+ @refresh_window = refresh_window
33
+ end
34
+
35
+ def token
36
+ token = @store.get_token
37
+ return nil if token.nil? || token.access_token.nil? || token.access_token.empty?
38
+
39
+ if token.expired? || token.will_expire_within?(@refresh_window)
40
+ if @manager
41
+ @manager.refresh
42
+ token = @store.get_token
43
+ end
44
+ end
45
+ token&.access_token
46
+ end
47
+ end
48
+
49
+ # Coordinates token storage, retrieval, and refresh. When configured with an
50
+ # oauth hash (base_url and optional client_id), #refresh exchanges the stored
51
+ # refresh token at the Doorkeeper token endpoint.
52
+ class AuthManager
53
+ def initialize(store:, provider: nil, oauth: nil)
54
+ @store = store
55
+ @provider = provider
56
+ @refresh_fn = oauth ? build_oauth_refresh(oauth) : nil
57
+ end
58
+
59
+ # Overrides the refresh function used by #refresh. Useful for tests and
60
+ # custom refresh flows.
61
+ def set_refresh_fn(fn = nil, &block)
62
+ @refresh_fn = fn || block
63
+ end
64
+
65
+ def token
66
+ return @provider.token if @provider
67
+
68
+ token = @store.get_token
69
+ token&.access_token
70
+ end
71
+
72
+ def refresh
73
+ token = @store.get_token
74
+ raise TokenError, "no token stored" if token.nil?
75
+ raise TokenError, "no refresh token stored" if token.refresh_token.nil? || token.refresh_token.empty?
76
+ raise TokenError, "OAuth refresh is not configured" if @refresh_fn.nil?
77
+
78
+ new_token = @refresh_fn.call(token.refresh_token)
79
+ @store.save_token(new_token)
80
+ new_token
81
+ end
82
+
83
+ def logout
84
+ @store.delete
85
+ end
86
+
87
+ private
88
+
89
+ def build_oauth_refresh(oauth)
90
+ oauth = {base_url: oauth} if oauth.is_a?(String)
91
+ base_url = oauth[:base_url]
92
+ client_id = oauth[:client_id] || OAuth::DEFAULT_CLIENT_ID
93
+ return nil if base_url.nil? || base_url.empty?
94
+
95
+ ->(refresh_token) { OAuth.refresh(base_url: base_url, refresh_token: refresh_token, client_id: client_id) }
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "uri"
7
+ require_relative "version"
8
+ require_relative "error"
9
+ require_relative "auth"
10
+ require_relative "config"
11
+ require_relative "credentials"
12
+ require_relative "pagination"
13
+
14
+ module Wenmar
15
+ class Client
16
+ DEFAULT_BASE_URL = "https://app.wenmarpro.com"
17
+
18
+ # Faraday's retry middleware raises RetriableResponse internally when a
19
+ # response status matches retry_statuses. The write connection must match
20
+ # only that synthetic exception so 429s retry while transport errors
21
+ # (Faraday::ConnectionFailed, etc.) do not — mutations are not safe to
22
+ # replay on a lost connection.
23
+ WRITE_RETRY_EXCEPTIONS = [Faraday::RetriableResponse].freeze
24
+
25
+ attr_reader :base_url, :location_id, :config
26
+
27
+ def initialize(config = nil, token: nil, base_url: nil, token_provider: nil)
28
+ @config = config.is_a?(Config) ? config : Config.new(config || {})
29
+ @config.access_token = token if token
30
+ @config.token_provider = token_provider if token_provider
31
+ @config.base_url = base_url if base_url
32
+
33
+ @base_url = (@config.base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
34
+ @location_id = @config.location_id
35
+ raise ArgumentError, "base_url must use https (http only allowed for localhost)" unless https_or_localhost?(@base_url)
36
+
37
+ @read_connection = build_connection(retry_statuses: [429, 500, 502, 503, 504])
38
+ @write_connection = build_connection(retry_statuses: [429], methods: %i[post patch delete], exceptions: WRITE_RETRY_EXCEPTIONS)
39
+ @cache = {}
40
+ @cache_mutex = Mutex.new
41
+ end
42
+
43
+ def https_or_localhost?(url)
44
+ parsed = URI.parse(url)
45
+ return true if parsed.scheme == "https"
46
+
47
+ %w[localhost 127.0.0.1].include?(parsed.host)
48
+ rescue URI::InvalidURIError
49
+ false
50
+ end
51
+ private :https_or_localhost?
52
+
53
+ def token_provider
54
+ @config.token_provider || StaticTokenProvider.new(@config.access_token)
55
+ end
56
+
57
+ def for_location(location_id)
58
+ scoped = dup
59
+ scoped.instance_variable_set(:@location_id, location_id)
60
+ scoped.instance_variable_set(:@cache, {})
61
+ scoped.instance_variable_set(:@cache_mutex, Mutex.new)
62
+ scoped.instance_variable_set(:@read_connection, build_connection(retry_statuses: [429, 500, 502, 503, 504], location_id: location_id))
63
+ scoped.instance_variable_set(:@write_connection, build_connection(retry_statuses: [429], methods: %i[post patch delete], exceptions: WRITE_RETRY_EXCEPTIONS, location_id: location_id))
64
+ scoped
65
+ end
66
+
67
+ def get(path, params = {})
68
+ cache_key = cache_key(path, params)
69
+ cached = if @config.cache_enabled
70
+ @cache_mutex.synchronize { @cache[cache_key] }
71
+ end
72
+
73
+ response = @read_connection.get(path, params) do |req|
74
+ if cached
75
+ req.headers["If-None-Match"] = cached[:etag] if cached[:etag]
76
+ req.headers["If-Modified-Since"] = cached[:last_modified] if cached[:last_modified]
77
+ end
78
+ end
79
+
80
+ if response.status == 304 && cached
81
+ response = Faraday::Response.new(
82
+ status: 200,
83
+ response_headers: cached[:headers],
84
+ body: cached[:body]
85
+ )
86
+ end
87
+
88
+ if @config.cache_enabled && response.status == 200 && (response.headers["ETag"] || response.headers["Last-Modified"])
89
+ @cache_mutex.synchronize do
90
+ @cache[cache_key] = {
91
+ etag: response.headers["ETag"],
92
+ last_modified: response.headers["Last-Modified"],
93
+ body: response.body,
94
+ headers: response.headers
95
+ }
96
+ end
97
+ end
98
+
99
+ handle_response(response)
100
+ end
101
+
102
+ def get_raw(url)
103
+ response = @read_connection.get(url) do |req|
104
+ req.headers["Accept"] = "application/json"
105
+ end
106
+ raise Wenmar::Error.from_response(response) if response.status >= 400
107
+
108
+ response
109
+ end
110
+
111
+ def post(path, body = nil, params = {})
112
+ response = @write_connection.post(path) do |req|
113
+ req.params.merge!(params) unless params.empty?
114
+ req.body = body.to_json if body
115
+ end
116
+ handle_response(response)
117
+ end
118
+
119
+ def patch(path, body = nil, params = {})
120
+ response = @write_connection.patch(path) do |req|
121
+ req.params.merge!(params) unless params.empty?
122
+ req.body = body.to_json if body
123
+ end
124
+ handle_response(response)
125
+ end
126
+
127
+ def delete(path, params = {})
128
+ response = @write_connection.delete(path) do |req|
129
+ req.params.merge!(params) unless params.empty?
130
+ end
131
+ handle_response(response)
132
+ end
133
+
134
+ # paginator_to_a collects all pages from a paginated list result, up to
135
+ # max items (default 1000). The returned array includes the initial page
136
+ # (the response body) plus every subsequent page fetched via the Link
137
+ # header. We intentionally do NOT delegate to Paginator#to_a: that method
138
+ # starts from the paginator's own (empty) data and would drop the initial
139
+ # page.
140
+ def paginator_to_a(result, max = 1000)
141
+ return result unless result.respond_to?(:paginator)
142
+
143
+ paginator = result.paginator
144
+ return result unless paginator
145
+
146
+ items = result.dup
147
+ while paginator.has_next? && items.size < max
148
+ page = paginator.next_page
149
+ break if page.nil?
150
+
151
+ items.concat(page)
152
+ end
153
+ items.first(max)
154
+ end
155
+
156
+ private
157
+
158
+ def build_connection(retry_statuses:, methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS, exceptions: [Faraday::Error], location_id: @location_id)
159
+ Faraday.new(url: @base_url) do |conn|
160
+ conn.headers["Accept"] = "application/json"
161
+ conn.headers["Content-Type"] = "application/json"
162
+ conn.headers["User-Agent"] = "wenmar-sdk-ruby/#{Wenmar::VERSION}"
163
+ conn.headers["X-Wenmar-Location"] = location_id if location_id
164
+ conn.options.timeout = @config.timeout
165
+ conn.options.open_timeout = @config.timeout
166
+ conn.request :authorization, "Bearer", -> { resolve_token }
167
+ conn.request :retry, retry_options.merge(
168
+ retry_statuses: retry_statuses,
169
+ methods: methods,
170
+ exceptions: exceptions
171
+ )
172
+ conn.adapter Faraday.default_adapter
173
+ end
174
+ end
175
+
176
+ # Builds the Faraday retry middleware options from @config.retry_options,
177
+ # defaulting max to @config.max_retries when not present. These may be
178
+ # overridden per-connection (retry_statuses/methods/exceptions) by callers
179
+ # of build_connection, which always win.
180
+ def retry_options
181
+ @config.retry_options.dup.tap do |opts|
182
+ opts[:max] = @config.max_retries unless opts.key?(:max) && opts[:max]
183
+ end
184
+ end
185
+
186
+ def resolve_token
187
+ token_provider.token
188
+ rescue TokenError => e
189
+ raise Error.new(code: "auth_failed", message: e.message)
190
+ end
191
+
192
+ def cache_key(path, params)
193
+ "#{location_id}|#{path_with_query(path, params)}"
194
+ end
195
+
196
+ def path_with_query(path, params)
197
+ return path if params.nil? || params.empty?
198
+
199
+ "#{path}?#{URI.encode_www_form(params)}"
200
+ end
201
+
202
+ def handle_response(response)
203
+ if response.status >= 400
204
+ raise Wenmar::Error.from_response(response)
205
+ end
206
+
207
+ body = response.body
208
+ return nil if body.nil? || body == ""
209
+
210
+ body = JSON.parse(body) if body.is_a?(String)
211
+
212
+ # Attach a paginator to list responses that carry a Link header.
213
+ if body.is_a?(Array) && response.headers["Link"]
214
+ client = self
215
+ body.define_singleton_method(:paginator) do
216
+ Paginator.from_response(response, client)
217
+ end
218
+ end
219
+ body
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wenmar
4
+ class Config
5
+ attr_accessor :base_url, :access_token, :token_provider, :location_id,
6
+ :timeout, :max_retries, :cache_enabled, :retry_options
7
+
8
+ def initialize(attrs = {})
9
+ @base_url = attrs[:base_url] || "https://app.wenmarpro.com"
10
+ @access_token = attrs[:access_token]
11
+ @token_provider = attrs[:token_provider]
12
+ @location_id = attrs[:location_id]
13
+ @timeout = attrs[:timeout] || 30
14
+ @max_retries = attrs[:max_retries] || 3
15
+ @cache_enabled = attrs.fetch(:cache_enabled, true)
16
+ @retry_options = attrs[:retry_options] || {
17
+ max: @max_retries,
18
+ interval: 0.1,
19
+ interval_randomness: 0.5,
20
+ backoff_factor: 2,
21
+ max_interval: 30
22
+ }
23
+ end
24
+
25
+ def self.from_env
26
+ new(
27
+ base_url: ENV.fetch("WENMAR_BASE_URL", "https://app.wenmarpro.com"),
28
+ timeout: ENV["WENMAR_TIMEOUT"]&.to_i || 30,
29
+ max_retries: ENV["WENMAR_MAX_RETRIES"]&.to_i || 3,
30
+ cache_enabled: ENV["WENMAR_CACHE"] != "false"
31
+ )
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Wenmar
7
+ class CredentialStore
8
+ TOKEN_KEY = "access_token"
9
+ LEGACY_TOKEN_KEY = "token"
10
+
11
+ def initialize(path = default_path)
12
+ @path = path
13
+ end
14
+
15
+ def load
16
+ return {} unless File.exist?(@path)
17
+
18
+ JSON.parse(File.read(@path))
19
+ rescue JSON::ParserError
20
+ {}
21
+ end
22
+
23
+ # Legacy: returns the access token string or nil. Prefer get_token.
24
+ def token
25
+ data = load
26
+ data[TOKEN_KEY] || data[LEGACY_TOKEN_KEY]
27
+ end
28
+
29
+ # Legacy: wraps a token string in a Wenmar::Token and persists it.
30
+ def save(token)
31
+ save_token(Token.new(access_token: token))
32
+ end
33
+
34
+ def get_token
35
+ data = load
36
+ return nil if data.empty?
37
+
38
+ return Token.new(access_token: data[LEGACY_TOKEN_KEY]) if data[LEGACY_TOKEN_KEY] && !data[TOKEN_KEY]
39
+
40
+ Token.from_h(data)
41
+ rescue TokenError
42
+ nil
43
+ end
44
+
45
+ def save_token(token)
46
+ token = Token.from_h(token) if token.is_a?(Hash)
47
+ FileUtils.mkdir_p(File.dirname(@path))
48
+ File.write(@path, JSON.pretty_generate(token.to_h), perm: 0o600)
49
+ end
50
+
51
+ def delete
52
+ FileUtils.rm_f(@path)
53
+ end
54
+
55
+ private
56
+
57
+ def default_path
58
+ File.join(Dir.home, ".config", "wenmar", "credentials.json")
59
+ end
60
+ end
61
+
62
+ class KeychainStore
63
+ SERVICE = "wenmar"
64
+ LEGACY_SERVICE = "wenmar-cli"
65
+ ACCOUNT = "token"
66
+
67
+ # The ruby-keychain gem is macOS-only and optional. It must not be a hard
68
+ # runtime dependency, so we require it lazily and only when a keychain
69
+ # operation is attempted. Keychain users must `gem install ruby-keychain`
70
+ # themselves.
71
+ def initialize
72
+ require_keychain
73
+ end
74
+
75
+ def get_token
76
+ password = read_password
77
+ return nil unless password
78
+
79
+ Token.from_h(JSON.parse(password))
80
+ rescue TokenError, JSON::ParserError
81
+ # A legacy plain-token entry has no JSON envelope; treat it as a bare
82
+ # access token.
83
+ bare = read_password
84
+ bare ? Token.new(access_token: bare) : nil
85
+ end
86
+
87
+ def save_token(token)
88
+ token = Token.from_h(token) if token.is_a?(Hash)
89
+ write_password(JSON.generate(token.to_h))
90
+ end
91
+
92
+ def delete
93
+ require_keychain
94
+ Keychain.generic_passwords.where(service: SERVICE, account: ACCOUNT).first&.destroy
95
+ end
96
+
97
+ private
98
+
99
+ def require_keychain
100
+ require "keychain"
101
+ rescue LoadError
102
+ raise TokenError, "The 'ruby-keychain' gem is required to use KeychainStore. Run `gem install ruby-keychain`."
103
+ end
104
+
105
+ def read_password
106
+ require_keychain
107
+ item = Keychain.generic_passwords.where(service: SERVICE, account: ACCOUNT).first
108
+ return item.password if item
109
+
110
+ legacy = Keychain.generic_passwords.where(service: LEGACY_SERVICE, account: ACCOUNT).first
111
+ return nil unless legacy
112
+
113
+ # Migrate a legacy entry into the current service.
114
+ write_password(legacy.password)
115
+ legacy.password
116
+ end
117
+
118
+ def write_password(password)
119
+ require_keychain
120
+ Keychain.generic_passwords.where(service: SERVICE, account: ACCOUNT).first&.destroy
121
+ Keychain.generic_passwords.create(service: SERVICE, account: ACCOUNT, password: password)
122
+ end
123
+ end
124
+ end