git-fit 0.10.6 → 0.10.7

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: 529a74a588a51b0b04ba1a0e15e629425dec93dd79986c6e6f45a0eda35d5914
4
- data.tar.gz: 37a81d2e9285b8917c13b36eb343d3b97de27709cf1d71a094216c7a645b3168
3
+ metadata.gz: 32616b46febca2fefadfe46d783de032676821f74e82c0a65e85f3a0def488f1
4
+ data.tar.gz: 4b409c0d20c049c50e804123d82639915e35b6df957c1543756e75e4a2d8d643
5
5
  SHA512:
6
- metadata.gz: 8ecb103ba98d635affa43058a695811d061d8be767754e3012efb23aa3a7eb4a85b5ab6041c371da1371fab1afc31100f94f8cf77e11a21ec900b38be29a3dcf
7
- data.tar.gz: 27610f67729cf037ac1a05772103abcc0899de47a484b929d4d62a4f34184c23921245c84f62ea308861ffa62c4e520e0d39223ecb8908e0c36966e0f6825d6c
6
+ metadata.gz: 39a504dad06283e1a891552b775d0df401f2a038343323e1084379882739a9fe669841b7195c8fa28d0bea136ac62b8e61cccc5c1eac9fb3ce4aca800d156a69
7
+ data.tar.gz: dfbb3028468e96f6b943d332856e27520b034d216504bbd6072e26e656700d9340b38991e83fc6b5d869cbdc9a745c2a1fa5e684c903425b18dc45e608eacfa9
data/lib/git-fit.rb CHANGED
@@ -93,4 +93,5 @@ require_relative 'git_fit/strava_web/file_check'
93
93
  require_relative 'git_fit/cli/strava'
94
94
  require_relative 'git_fit/auth/garmin_token'
95
95
  require_relative 'git_fit/auth/strava'
96
+ require_relative 'git_fit/auth/garmin'
96
97
  require_relative 'git_fit/cli'
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../sync/garmin_base'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'base64'
8
+
9
+ module GitFit
10
+ module Auth
11
+ module Garmin
12
+ module DIExchange
13
+ DI_CLIENT_IDS = %w[
14
+ GARMIN_CONNECT_MOBILE_ANDROID_DI_2025Q2
15
+ GARMIN_CONNECT_MOBILE_ANDROID_DI_2024Q4
16
+ GARMIN_CONNECT_MOBILE_ANDROID_DI
17
+ ].freeze
18
+
19
+ DI_TOKEN_URL = 'https://diauth.garmin.com/di-oauth2-service/oauth/token'
20
+ DI_GRANT_TYPE = 'https://connectapi.garmin.com/di-oauth2-service/oauth/grant/service_ticket'
21
+ DI_USER_AGENT = 'GCM-Android-5.23'
22
+
23
+ DEFAULT_EXPIRES_IN = 64800
24
+
25
+ module_function
26
+
27
+ def call(domain, ticket, service_url)
28
+ DI_CLIENT_IDS.each do |cid|
29
+ token = attempt(cid, domain, ticket, service_url)
30
+ return token if token
31
+ end
32
+
33
+ raise GitFit::Sync::AuthError, 'All DI client IDs failed for ticket exchange'
34
+ end
35
+
36
+ def build_url(domain)
37
+ DI_TOKEN_URL.sub('garmin.com', domain)
38
+ end
39
+
40
+ def attempt(cid, domain, ticket, service_url)
41
+ resp = post(cid, domain, ticket, service_url)
42
+ unless resp.code.to_i == 200
43
+ warn "DI exchange #{cid}: HTTP #{resp.code}"
44
+ return nil
45
+ end
46
+
47
+ token = JSON.parse(resp.body)
48
+ token['di_client_id'] = cid
49
+ token['expires_at'] = Time.now.to_i + (token['expires_in'] || DEFAULT_EXPIRES_IN).to_i
50
+ if token['refresh_token_expires_in']
51
+ token['refresh_token_expires_at'] = Time.now.to_i + token['refresh_token_expires_in'].to_i
52
+ end
53
+ token
54
+ rescue StandardError => e
55
+ warn "DI exchange #{cid}: #{e.message}"
56
+ nil
57
+ end
58
+
59
+ def post(cid, domain, ticket, service_url)
60
+ uri = URI(build_url(domain))
61
+ req = Net::HTTP::Post.new(uri)
62
+ req['Authorization'] = "Basic #{Base64.strict_encode64("#{cid}:")}"
63
+ req['Accept'] = 'application/json,text/html;q=0.9,*/*;q=0.8'
64
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
65
+ req['Cache-Control'] = 'no-cache'
66
+ req['User-Agent'] = DI_USER_AGENT
67
+ req.body = URI.encode_www_form(
68
+ client_id: cid,
69
+ service_ticket: ticket,
70
+ grant_type: DI_GRANT_TYPE,
71
+ service_url: service_url,
72
+ )
73
+ send_http(uri, req)
74
+ end
75
+
76
+ def send_http(uri, request)
77
+ http = Net::HTTP.new(uri.host, uri.port)
78
+ http.use_ssl = uri.scheme == 'https'
79
+ http.open_timeout = 30
80
+ http.read_timeout = 60
81
+ http.start { |h| h.request(request) }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'di_exchange'
4
+ require_relative '../../sync/garmin_base'
5
+ require 'open3'
6
+ require 'tmpdir'
7
+ require 'uri'
8
+ require 'json'
9
+
10
+ module GitFit
11
+ module Auth
12
+ module Garmin
13
+ # Strategy A: curl-impersonate shellout (faithful Ruby port of
14
+ # workouts/scripts/garmin_auth.py).
15
+ #
16
+ # mobile+cffi (primary): single Safari TLS session, /mobile/api/login
17
+ # portal+cffi (fallback): 5 TLS fingerprints, /portal/api/login
18
+ #
19
+ # A 30-45s random delay before each GET sign-in is critical for the
20
+ # Cloudflare WAF bypass — keep it.
21
+ # rubocop:disable Metrics/ModuleLength
22
+ module StrategyA
23
+ PORTAL_CLIENT_ID = 'GarminConnect'
24
+ PORTAL_SERVICE = 'https://connect.garmin.com/app'
25
+ MOBILE_CLIENT_ID = 'GCM_ANDROID_DARK'
26
+ MOBILE_SERVICE = 'https://mobile.integration.garmin.com/gcm/android'
27
+ MOBILE_UA = 'Mozilla/5.0 (Linux; Android 13; sdk_gphone64_arm64 Build/TE1A.220922.025; wv) ' \
28
+ 'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/132.0.0.0 ' \
29
+ 'Mobile Safari/537.36'
30
+ DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' \
31
+ 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
32
+ HTML_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
33
+ JSON_ACCEPT = 'application/json, text/plain, */*'
34
+ LOGIN_DELAY_MIN = 30.0
35
+ LOGIN_DELAY_MAX = 45.0
36
+ TLS_FINGERPRINTS = %w[safari safari_ios chrome120 edge101 chrome].freeze
37
+ CURL_BINARY = 'curl-impersonate'
38
+
39
+ module_function
40
+
41
+ def call(email:, password:, domain: 'garmin.com', mfa_code: nil)
42
+ check_curl_available!
43
+
44
+ errors = []
45
+ begin
46
+ return finish(mobile_login(domain, email, password, mfa_code), domain)
47
+ rescue GitFit::Sync::AuthError => e
48
+ errors << "mobile+cffi: #{e.message}"
49
+ warn "[garmin_auth] #{errors.last}"
50
+ rescue StandardError => e
51
+ errors << "mobile+cffi: unexpected error: #{e.message}"
52
+ warn "[garmin_auth] #{errors.last}"
53
+ end
54
+
55
+ begin
56
+ return finish(portal_login(domain, email, password, mfa_code), domain)
57
+ rescue GitFit::Sync::AuthError => e
58
+ errors << "portal+cffi: #{e.message}"
59
+ warn "[garmin_auth] #{errors.last}"
60
+ rescue StandardError => e
61
+ errors << "portal+cffi: unexpected error: #{e.message}"
62
+ warn "[garmin_auth] #{errors.last}"
63
+ end
64
+
65
+ raise GitFit::Sync::AuthError, "All strategies exhausted: #{errors.join('; ')}"
66
+ end
67
+
68
+ def random_delay(label)
69
+ delay = rand(LOGIN_DELAY_MIN..LOGIN_DELAY_MAX)
70
+ warn "[garmin_auth] #{label}: waiting #{delay.to_i}s for Cloudflare..."
71
+ sleep(delay)
72
+ end
73
+
74
+ def mobile_login(domain, email, password, mfa_code)
75
+ sso = "https://sso.#{domain}"
76
+ with_session_files do |jar, body|
77
+ mobile_login_session(sso, email, password, mfa_code, jar, body)
78
+ end
79
+ end
80
+
81
+ def mobile_login_session(sso, email, password, mfa_code, jar, body)
82
+ random_delay('mobile+cffi')
83
+ signin_url = "#{sso}/mobile/sso/en_US/sign-in"
84
+ get_params = { clientId: MOBILE_CLIENT_ID, service: MOBILE_SERVICE }
85
+ get_headers = {
86
+ 'User-Agent' => MOBILE_UA,
87
+ 'accept' => HTML_ACCEPT,
88
+ 'accept-language' => 'en-US,en;q=0.9',
89
+ }
90
+
91
+ status, = curl_request(
92
+ impersonate: 'safari',
93
+ jar: jar,
94
+ output: body,
95
+ url: with_query(signin_url, get_params),
96
+ headers: get_headers,
97
+ )
98
+ raise GitFit::Sync::AuthError, 'mobile+cffi GET 429' if status == 429
99
+ raise GitFit::Sync::AuthError, "mobile+cffi GET #{status}" unless status.between?(200, 299)
100
+
101
+ post_headers = get_headers.merge(
102
+ 'accept' => JSON_ACCEPT,
103
+ 'content-type' => 'application/json',
104
+ 'origin' => sso,
105
+ 'referer' => "#{signin_url}?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}",
106
+ )
107
+ login_params = { clientId: MOBILE_CLIENT_ID, locale: 'en-US', service: MOBILE_SERVICE }
108
+ login_body = JSON.generate(
109
+ username: email,
110
+ password: password,
111
+ rememberMe: true,
112
+ captchaToken: '',
113
+ )
114
+
115
+ status, resp_body = curl_request(
116
+ impersonate: 'safari',
117
+ jar: jar,
118
+ output: body,
119
+ url: with_query("#{sso}/mobile/api/login", login_params),
120
+ headers: post_headers,
121
+ data: login_body,
122
+ )
123
+ raise GitFit::Sync::AuthError, 'mobile+cffi POST 429' if status == 429
124
+ raise GitFit::Sync::AuthError, "mobile+cffi POST #{status}" unless status.between?(200, 299)
125
+
126
+ res = JSON.parse(resp_body)
127
+ resp_type = res.dig('responseStatus', 'type')
128
+ if resp_type == 'MFA_REQUIRED'
129
+ method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
130
+ res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
131
+ impersonate: 'safari', jar: jar, output: body)
132
+ resp_type = res.dig('responseStatus', 'type')
133
+ end
134
+
135
+ if resp_type == 'SUCCESSFUL'
136
+ [res['serviceTicketId'], MOBILE_SERVICE]
137
+ elsif resp_type == 'INVALID_USERNAME_PASSWORD'
138
+ raise GitFit::Sync::AuthError, 'Invalid username or password'
139
+ else
140
+ raise GitFit::Sync::AuthError, "mobile+cffi: unexpected response type: #{resp_type}"
141
+ end
142
+ end
143
+
144
+ def portal_login(domain, email, password, mfa_code)
145
+ sso = "https://sso.#{domain}"
146
+ TLS_FINGERPRINTS.each do |imp|
147
+ result = attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
148
+ return result if result
149
+ end
150
+ raise GitFit::Sync::AuthError, 'portal+cffi: all TLS fingerprints exhausted'
151
+ end
152
+
153
+ def attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
154
+ with_session_files do |jar, body|
155
+ attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
156
+ end
157
+ end
158
+
159
+ def attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
160
+ random_delay("portal+cffi/#{imp}")
161
+ signin_url = "#{sso}/portal/sso/en-US/sign-in"
162
+ get_params = { clientId: PORTAL_CLIENT_ID, service: PORTAL_SERVICE }
163
+ get_headers = browser_headers.merge('Accept' => HTML_ACCEPT)
164
+
165
+ status, = curl_request(
166
+ impersonate: imp,
167
+ jar: jar,
168
+ output: body,
169
+ url: with_query(signin_url, get_params),
170
+ headers: get_headers,
171
+ )
172
+ if status == 429
173
+ warn "[garmin_auth] portal+cffi/#{imp} GET 429"
174
+ return nil
175
+ end
176
+ unless status.between?(200, 299)
177
+ warn "[garmin_auth] portal+cffi/#{imp} GET #{status}"
178
+ return nil
179
+ end
180
+
181
+ post_headers = browser_headers.merge(
182
+ 'Accept' => JSON_ACCEPT,
183
+ 'Content-Type' => 'application/json',
184
+ 'Origin' => sso,
185
+ 'Referer' => "#{signin_url}?clientId=#{PORTAL_CLIENT_ID}&service=#{PORTAL_SERVICE}",
186
+ )
187
+ login_params = { clientId: PORTAL_CLIENT_ID, locale: 'en-US', service: PORTAL_SERVICE }
188
+ login_body = JSON.generate(
189
+ username: email,
190
+ password: password,
191
+ rememberMe: true,
192
+ captchaToken: '',
193
+ )
194
+
195
+ status, resp_body = curl_request(
196
+ impersonate: imp,
197
+ jar: jar,
198
+ output: body,
199
+ url: with_query("#{sso}/portal/api/login", login_params),
200
+ headers: post_headers,
201
+ data: login_body,
202
+ )
203
+ if status == 429
204
+ warn "[garmin_auth] portal+cffi/#{imp} POST 429"
205
+ return nil
206
+ end
207
+ unless status.between?(200, 299)
208
+ warn "[garmin_auth] portal+cffi/#{imp} POST #{status}"
209
+ return nil
210
+ end
211
+
212
+ res = JSON.parse(resp_body)
213
+ resp_type = res.dig('responseStatus', 'type')
214
+ if resp_type == 'MFA_REQUIRED'
215
+ method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
216
+ res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
217
+ impersonate: imp, jar: jar, output: body)
218
+ resp_type = res.dig('responseStatus', 'type')
219
+ end
220
+
221
+ return [res['serviceTicketId'], PORTAL_SERVICE] if resp_type == 'SUCCESSFUL'
222
+ raise GitFit::Sync::AuthError, 'Invalid username or password' if resp_type == 'INVALID_USERNAME_PASSWORD'
223
+
224
+ nil
225
+ rescue GitFit::Sync::AuthError
226
+ raise
227
+ rescue StandardError => e
228
+ warn "[garmin_auth] portal+cffi/#{imp} error: #{e.message}"
229
+ nil
230
+ end
231
+
232
+ def handle_mfa(sso, method, login_params, post_headers, mfa_code, impersonate:, jar:, output:)
233
+ code = resolve_mfa_code(mfa_code)
234
+ mfa_data = {
235
+ 'mfaMethod' => method,
236
+ 'mfaVerificationCode' => code,
237
+ 'rememberMyBrowser' => true,
238
+ 'reconsentList' => [],
239
+ 'mfaSetup' => false,
240
+ }
241
+
242
+ endpoints = [
243
+ ["#{sso}/portal/api/mfa/verifyCode", login_params],
244
+ ["#{sso}/mobile/api/mfa/verifyCode",
245
+ { clientId: MOBILE_CLIENT_ID, locale: 'en-US', service: MOBILE_SERVICE }],
246
+ ]
247
+
248
+ endpoints.each do |ep_url, ep_params|
249
+ status, resp_body = curl_request(
250
+ impersonate: impersonate,
251
+ jar: jar,
252
+ output: output,
253
+ url: with_query(ep_url, ep_params),
254
+ headers: post_headers,
255
+ data: JSON.generate(mfa_data),
256
+ )
257
+ next if status == 429
258
+ next unless status.between?(200, 299)
259
+
260
+ res = JSON.parse(resp_body)
261
+ return res if res.dig('responseStatus', 'type') == 'SUCCESSFUL'
262
+ rescue StandardError
263
+ next
264
+ end
265
+ raise GitFit::Sync::AuthError, 'MFA verification failed on all endpoints'
266
+ end
267
+
268
+ def resolve_mfa_code(mfa_code)
269
+ code = mfa_code
270
+ code = ENV['SYNC__GARMIN__MFA_CODE'] if code.nil? || code.to_s.empty?
271
+ if (code.nil? || code.to_s.empty?) && $stdin.tty?
272
+ $stderr.write('Enter MFA code: ')
273
+ $stderr.flush
274
+ code = $stdin.gets
275
+ end
276
+ if code.nil? || code.to_s.empty?
277
+ raise GitFit::Sync::AuthError,
278
+ 'MFA required but SYNC__GARMIN__MFA_CODE env var not set and stdin unavailable'
279
+ end
280
+ code = code.strip
281
+ raise GitFit::Sync::AuthError, 'SYNC__GARMIN__MFA_CODE is empty' if code.empty?
282
+
283
+ code
284
+ end
285
+
286
+ def check_curl_available!
287
+ _out, _err, status = Open3.capture3(CURL_BINARY, '--version')
288
+ return if status.success?
289
+
290
+ raise GitFit::Sync::AuthError, "#{CURL_BINARY} not found. Install it or use -B strategy"
291
+ rescue Errno::ENOENT
292
+ raise GitFit::Sync::AuthError, "#{CURL_BINARY} not found. Install it or use -B strategy"
293
+ end
294
+
295
+ def curl_request(impersonate:, jar:, output:, url:, headers:, data: nil)
296
+ args = [CURL_BINARY, '--impersonate', impersonate, '-s', '-S', '-L']
297
+ args += ['-c', jar, '-b', jar]
298
+ headers.each { |key, value| args += ['-H', "#{key}: #{value}"] }
299
+ args += ['--data-raw', data] if data
300
+ args += ['-w', "\n%{http_code}", '-o', output, url] # rubocop:disable Style/FormatStringToken
301
+ out, = Open3.capture3(*args)
302
+ code = out.strip.split.last.to_i
303
+ [code, File.read(output)]
304
+ end
305
+
306
+ def browser_headers
307
+ {
308
+ 'User-Agent' => DESKTOP_UA,
309
+ 'Accept-Language' => 'en-US,en;q=0.9',
310
+ }
311
+ end
312
+
313
+ def with_query(url, params)
314
+ return url if params.nil? || params.empty?
315
+
316
+ "#{url}?#{URI.encode_www_form(params)}"
317
+ end
318
+
319
+ def with_session_files
320
+ Dir.mktmpdir('garmin-strategy-a-') do |dir|
321
+ yield File.join(dir, 'cookies.txt'), File.join(dir, 'body.txt')
322
+ end
323
+ end
324
+
325
+ def finish((ticket, service_url), domain)
326
+ DIExchange.call(domain, ticket, service_url)
327
+ end
328
+ end
329
+ # rubocop:enable Metrics/ModuleLength
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'di_exchange'
4
+ require_relative '../../sync/garmin_base'
5
+ require 'json'
6
+ require 'fileutils'
7
+
8
+ module GitFit
9
+ module Auth
10
+ module Garmin
11
+ # rubocop:disable Metrics/ModuleLength
12
+ module StrategyB
13
+ MOBILE_UA = 'Mozilla/5.0 (Linux; Android 13; sdk_gphone64_arm64 Build/TE1A.220922.025; wv) ' \
14
+ 'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/132.0.0.0 ' \
15
+ 'Mobile Safari/537.36'
16
+ MOBILE_CLIENT_ID = 'GCM_ANDROID_DARK'
17
+ MOBILE_SERVICE = 'https://mobile.integration.garmin.com/gcm/android'
18
+ LOGIN_DELAY_MIN = 30.0
19
+ LOGIN_DELAY_MAX = 45.0
20
+ SESSION_CACHE_DIR = 'data/cache'
21
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
22
+
23
+ module_function
24
+
25
+ def call(email:, password:, domain: 'garmin.com', mfa_code: nil)
26
+ mfa_code = mfa_code.to_s.strip
27
+ mfa_code = ENV['SYNC__GARMIN__MFA_CODE'].to_s.strip if mfa_code.empty?
28
+ sso = "https://sso.#{domain}"
29
+ session_path = _session_file_path(domain)
30
+ chrome_path = _find_chrome
31
+
32
+ _launch_playwright(chrome_path) do |browser|
33
+ session = _load_session(session_path)
34
+ warn "loaded session cookies (#{session[:age_days].round} days old)" if session
35
+
36
+ opts = { user_agent: MOBILE_UA, viewport: { width: 375, height: 812 } }
37
+ opts[:storage_state] = session[:path] if session
38
+ context = browser.new_context(**opts)
39
+
40
+ begin
41
+ ticket, service_url = _mobile_api_login(context.request, domain, email, password, mfa_code, sso)
42
+ token = DIExchange.call(domain, ticket, service_url)
43
+ _save_session(context, session_path)
44
+ token
45
+ ensure
46
+ context.close
47
+ end
48
+ end
49
+ end
50
+
51
+ def _mobile_api_login(context, _domain, email, password, mfa_code, sso)
52
+ headers = {
53
+ 'User-Agent' => MOBILE_UA,
54
+ 'Accept' => 'application/json, text/plain, */*',
55
+ 'Content-Type' => 'application/json',
56
+ 'Origin' => sso,
57
+ 'Accept-Language' => 'en-US,en;q=0.9',
58
+ }
59
+ params = { 'clientId' => MOBILE_CLIENT_ID, 'locale' => 'en-US', 'service' => MOBILE_SERVICE }
60
+ referer = "#{sso}/mobile/sso/en_US/sign-in?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}"
61
+
62
+ signin_resp = context.get(
63
+ "#{sso}/mobile/sso/en_US/sign-in",
64
+ params: { 'clientId' => MOBILE_CLIENT_ID, 'service' => MOBILE_SERVICE },
65
+ headers: { 'User-Agent' => MOBILE_UA, 'Accept' => 'text/html,...', 'Accept-Language' => 'en-US,en;q=0.9' },
66
+ max_redirects: 0,
67
+ )
68
+
69
+ if REDIRECT_STATUSES.include?(signin_resp.status)
70
+ location = signin_resp.headers['location'].to_s
71
+ ticket = _extract_ticket_from_url(location)
72
+ if ticket
73
+ warn "session valid — ticket from redirect: #{ticket[0, 20]}..."
74
+ return [ticket, MOBILE_SERVICE]
75
+ end
76
+ warn "302 redirect but no ticket in: #{location[0, 80]}..."
77
+ end
78
+
79
+ _random_delay('mobile')
80
+ warn 'posting login credentials...'
81
+ resp = context.post(
82
+ "#{sso}/mobile/api/login",
83
+ params: params,
84
+ headers: headers.merge('Referer' => referer),
85
+ data: JSON.generate(
86
+ 'username' => email,
87
+ 'password' => password,
88
+ 'rememberMe' => true,
89
+ 'captchaToken' => '',
90
+ ),
91
+ )
92
+
93
+ raise GitFit::Sync::AuthError, 'mobile API login 429 (rate limited by Cloudflare)' if resp.status == 429
94
+
95
+ data = resp.json
96
+ resp_type = data.dig('responseStatus', 'type')
97
+
98
+ raise GitFit::Sync::AuthError, 'Invalid username or password' if resp_type == 'INVALID_USERNAME_PASSWORD'
99
+
100
+ return [data['serviceTicketId'], MOBILE_SERVICE] if resp_type == 'SUCCESSFUL'
101
+
102
+ raise GitFit::Sync::AuthError, "Unexpected login response: #{resp_type}" unless resp_type == 'MFA_REQUIRED'
103
+
104
+ mfa_method = data.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
105
+ warn "MFA required (method: #{mfa_method})"
106
+
107
+ code = mfa_code.to_s.strip
108
+ if code.empty? && $stdin.tty?
109
+ warn 'Enter MFA code: '
110
+ code = $stdin.gets.to_s.strip
111
+ end
112
+ if code.empty?
113
+ raise GitFit::Sync::AuthError, 'MFA required but no code provided (set SYNC__GARMIN__MFA_CODE or use stdin)'
114
+ end
115
+
116
+ warn 'verifying MFA code...'
117
+ mfa_data = {
118
+ 'mfaMethod' => mfa_method,
119
+ 'mfaVerificationCode' => code,
120
+ 'rememberMyBrowser' => true,
121
+ 'reconsentList' => [],
122
+ 'mfaSetup' => false,
123
+ }
124
+
125
+ resp = context.post(
126
+ "#{sso}/mobile/api/mfa/verifyCode",
127
+ params: params,
128
+ headers: headers.merge('Referer' => referer),
129
+ data: JSON.generate(mfa_data),
130
+ )
131
+
132
+ data = resp.json
133
+ resp_type = data.dig('responseStatus', 'type')
134
+
135
+ return [data['serviceTicketId'], MOBILE_SERVICE] if resp_type == 'SUCCESSFUL'
136
+
137
+ if resp_type == 'MFA_CODE_INVALID'
138
+ raise GitFit::Sync::AuthError, "MFA code invalid — #{data.dig('responseStatus', 'message') || 'unknown'}"
139
+ end
140
+
141
+ raise GitFit::Sync::AuthError, "Unexpected MFA response: #{resp_type}"
142
+ end
143
+
144
+ def _extract_ticket_from_url(url)
145
+ url[/[?&]ticket=(ST-[^&\s]+)/, 1]
146
+ end
147
+
148
+ def _random_delay(label)
149
+ delay = rand(LOGIN_DELAY_MIN..LOGIN_DELAY_MAX)
150
+ warn "[garmin_auth_pw] #{label}: waiting #{delay.round}s for Cloudflare..."
151
+ sleep(delay)
152
+ end
153
+
154
+ def _session_file_path(domain)
155
+ File.join(SESSION_CACHE_DIR, "garmin_playwright_session_#{domain.tr('.', '_')}.json")
156
+ end
157
+
158
+ def _load_session(session_path)
159
+ return nil unless File.exist?(session_path)
160
+
161
+ data = JSON.parse(File.read(session_path))
162
+ age_days = (Time.now.to_f - (data['saved_at'] || 0).to_f) / 86_400.0
163
+ { path: session_path, age_days: age_days }
164
+ rescue StandardError => e
165
+ warn "failed to load session cookies: #{e.message}"
166
+ File.delete(session_path) if File.exist?(session_path)
167
+ nil
168
+ end
169
+
170
+ def _save_session(context, session_path)
171
+ FileUtils.mkdir_p(File.dirname(session_path))
172
+ state = context.storage_state
173
+ state['saved_at'] = Time.now.to_f
174
+ File.write(session_path, JSON.generate(state))
175
+ rescue StandardError => e
176
+ warn "failed to save session cookies: #{e.message}"
177
+ end
178
+
179
+ def _find_chrome
180
+ candidates = []
181
+ env_path = ENV['GIT_FIT_GARMIN_CHROMIUM_PATH'].to_s.strip
182
+ candidates << env_path unless env_path.empty?
183
+ candidates.concat(%w[/usr/bin/google-chrome-beta /usr/bin/google-chrome /usr/bin/chromium])
184
+
185
+ found = candidates.find { |path| File.exist?(path) }
186
+ return found if found
187
+
188
+ raise GitFit::Sync::AuthError, 'Chrome not found. Set GIT_FIT_GARMIN_CHROMIUM_PATH or use -A strategy'
189
+ end
190
+
191
+ def _launch_playwright(chrome_path)
192
+ begin
193
+ require 'playwright'
194
+ rescue LoadError
195
+ raise GitFit::Sync::AuthError, 'playwright-ruby-client gem missing. bundle install, or use -A strategy'
196
+ end
197
+
198
+ Playwright.create do |playwright|
199
+ browser = playwright.chromium.launch(headless: true, executable_path: chrome_path)
200
+ begin
201
+ yield browser
202
+ ensure
203
+ browser.close
204
+ end
205
+ end
206
+ rescue GitFit::Sync::AuthError
207
+ raise
208
+ rescue StandardError => e
209
+ raise GitFit::Sync::AuthError, "Playwright launch failed: #{e.message}"
210
+ end
211
+ end
212
+ # rubocop:enable Metrics/ModuleLength
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'garmin/strategy_a'
4
+ require_relative 'garmin/strategy_b'
5
+ require_relative 'garmin/di_exchange'
6
+ require_relative '../config'
7
+ require_relative '../cli/gh_cli'
8
+ require 'json'
9
+ require 'yaml'
10
+ require 'base64'
11
+ require 'fileutils'
12
+ require 'open3'
13
+
14
+ module GitFit
15
+ module Auth
16
+ module Garmin
17
+ # Orchestrator for `git fit auth garmin` — faithful Ruby port of
18
+ # workouts/scripts/garmin_auth_local.sh with the finalized
19
+ # -A/-B/--auto/--sync CLI interface. The module namespace is shared with
20
+ # the strategies (StrategyA/StrategyB/DIExchange), so the orchestrator
21
+ # lives in a nested class and `GitFit::Auth::Garmin.new` delegates to it
22
+ # (same call site as GitFit::Auth::Strava).
23
+ SEED_SECRET_NAME = 'SYNC_GARMIN_AUTH_SEED'
24
+ FLAGS = %i[A B auto sync].freeze
25
+ STRATEGIES = { A: StrategyA, B: StrategyB }.freeze
26
+
27
+ class Orchestrator
28
+ include GitFit::GhHelpers
29
+
30
+ def initialize(options, config)
31
+ @options = options || {}
32
+ @config = config
33
+ end
34
+
35
+ attr_reader :generated_secret
36
+
37
+ def call
38
+ mode = selected_mode
39
+ token = mode == :sync ? sync_token : sso_token(mode)
40
+ persist(token)
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def selected_mode
47
+ chosen = FLAGS.select { |flag| flag_enabled?(flag) }
48
+ if chosen.size > 1
49
+ raise GitFit::Sync::AuthError,
50
+ 'git fit auth garmin: -A/-B/--auto/--sync are mutually exclusive — ' \
51
+ 'use exactly one (default: -B)'
52
+ end
53
+
54
+ chosen.first || :B
55
+ end
56
+
57
+ def flag_enabled?(flag)
58
+ @options[flag] || @options[flag.to_s]
59
+ end
60
+
61
+ def sso_token(mode)
62
+ email, password = sso_credentials
63
+ creds = { email: email, password: password, domain: domain }
64
+ case mode
65
+ when :A then single_strategy(:A, creds)
66
+ when :B then single_strategy(:B, creds)
67
+ when :auto then auto_strategy(creds)
68
+ end
69
+ end
70
+
71
+ def sso_credentials
72
+ cfg = @config.sync_config('garmin')
73
+ email = cfg['email'].to_s
74
+ password = cfg['password'].to_s
75
+ if email.empty? || password.empty?
76
+ exit_with_error('sync.garmin.email / sync.garmin.password not set in config.yml — ' \
77
+ 'run: git fit init')
78
+ end
79
+
80
+ [email, password]
81
+ end
82
+
83
+ def single_strategy(label, creds)
84
+ token, error = run_strategy(label, creds)
85
+ exit_with_error("Garmin auth failed: #{error}") unless token
86
+
87
+ token
88
+ end
89
+
90
+ def auto_strategy(creds)
91
+ token, b_error = run_strategy(:B, creds)
92
+ return token if token
93
+
94
+ warn '[auto] B failed, falling back to A'
95
+ token, a_error = run_strategy(:A, creds)
96
+ return token if token
97
+
98
+ exit_with_error("Garmin auth failed: B: #{b_error}; A: #{a_error}")
99
+ end
100
+
101
+ def run_strategy(label, creds)
102
+ token = STRATEGIES.fetch(label).call(**creds)
103
+ return [token, nil] if json_round_trips?(token)
104
+
105
+ [nil, "Strategy #{label} returned a token that fails JSON round-trip"]
106
+ rescue GitFit::Sync::AuthError => e
107
+ [nil, e.message]
108
+ rescue StandardError => e
109
+ [nil, "unexpected error: #{e.message}"]
110
+ end
111
+
112
+ def json_round_trips?(token)
113
+ JSON.parse(JSON.generate(token))
114
+ true
115
+ rescue StandardError
116
+ false
117
+ end
118
+
119
+ def sync_token
120
+ path = token_path
121
+ unless File.exist?(path)
122
+ exit_with_error("Cache file not found: #{path}\n" \
123
+ 'Run without --sync first to perform SSO auth (git fit auth garmin).')
124
+ end
125
+
126
+ JSON.parse(File.read(path))
127
+ rescue JSON::ParserError => e
128
+ exit_with_error("Cache file is not valid JSON: #{path} (#{e.message})")
129
+ end
130
+
131
+ def persist(token)
132
+ @generated_secret = Base64.strict_encode64(JSON.generate(token))
133
+ write_token_cache(token)
134
+ write_config_auth_seed(@generated_secret)
135
+ gh_ladder(@generated_secret)
136
+ puts 'Done. Token cached in config.yml. CI will auto-refresh via di_refresh (30d sliding).'
137
+ end
138
+
139
+ def write_token_cache(token)
140
+ path = token_path
141
+ FileUtils.mkdir_p(File.dirname(path))
142
+ File.write(path, JSON.pretty_generate(token))
143
+ rescue StandardError => e
144
+ warn "Failed to write token cache: #{e.message}"
145
+ end
146
+
147
+ def write_config_auth_seed(seed)
148
+ cfg = File.exist?(config_path) ? (YAML.safe_load_file(config_path) || {}) : {}
149
+ cfg['sync'] ||= {}
150
+ cfg['sync']['garmin'] ||= {}
151
+ cfg['sync']['garmin']['auth_seed'] = seed
152
+ File.write(config_path, YAML.dump(cfg))
153
+ puts 'sync.garmin.auth_seed updated in config.yml'
154
+ rescue StandardError => e
155
+ warn "Failed to update config.yml: #{e.message}"
156
+ end
157
+
158
+ def config_path
159
+ File.expand_path(@options[:config] || 'config/config.yml')
160
+ end
161
+
162
+ def garmin_cfg
163
+ @config.sync_config('garmin')
164
+ end
165
+
166
+ def domain
167
+ garmin_cfg['domain'] || 'garmin.com'
168
+ end
169
+
170
+ def token_path
171
+ garmin_cfg['token_path'] ||
172
+ File.join('data', 'auth', "garmin_#{domain.tr('.', '_')}_tokens.json")
173
+ end
174
+
175
+ def gh_ladder(seed)
176
+ unless gh_installed?
177
+ warn 'gh CLI not found — install gh or set the secret manually'
178
+ print_secret_help(seed)
179
+ return
180
+ end
181
+ unless gh_auth_status?
182
+ warn 'gh CLI not authenticated — run: gh auth login'
183
+ print_secret_help(seed)
184
+ return
185
+ end
186
+
187
+ repo = detect_repo
188
+ unless gh_repo_view?(repo)
189
+ warn "No access to repo #{repo} — token only written to config.yml"
190
+ print_secret_help(seed)
191
+ return
192
+ end
193
+ unless gh_secret_list?(repo)
194
+ warn "No permission to write GitHub Secrets on #{repo}"
195
+ print_secret_help(seed)
196
+ return
197
+ end
198
+
199
+ if gh_secret_set(seed, repo)
200
+ puts "GitHub Secret set: #{SEED_SECRET_NAME}"
201
+ else
202
+ warn 'Failed to set GitHub Secret (unexpected error)'
203
+ print_secret_help(seed)
204
+ end
205
+ end
206
+
207
+ def gh_installed?
208
+ gh_success?('gh', '--version')
209
+ end
210
+
211
+ def gh_auth_status?
212
+ gh_success?('gh', 'auth', 'status')
213
+ end
214
+
215
+ def gh_repo_view?(repo)
216
+ gh_success?('gh', 'repo', 'view', repo)
217
+ end
218
+
219
+ def gh_secret_list?(repo)
220
+ gh_success?('gh', 'secret', 'list', '-R', repo)
221
+ end
222
+
223
+ def gh_secret_set(seed, repo)
224
+ _out, _err, status = Open3.capture3('gh', 'secret', 'set', SEED_SECRET_NAME, '-R', repo, stdin_data: seed)
225
+ status.success?
226
+ rescue Errno::ENOENT
227
+ false
228
+ end
229
+
230
+ def gh_success?(*args)
231
+ _out, _err, status = Open3.capture3(*args)
232
+ status.success?
233
+ rescue Errno::ENOENT
234
+ false
235
+ end
236
+
237
+ def print_secret_help(seed)
238
+ warn ''
239
+ warn '==========================================='
240
+ warn ' GitHub Actions Secret (one-time setup)'
241
+ warn " Name: #{SEED_SECRET_NAME}"
242
+ warn '==========================================='
243
+ warn ''
244
+ warn "Name: #{SEED_SECRET_NAME}"
245
+ warn "Value: #{seed}"
246
+ warn ''
247
+ end
248
+
249
+ def exit_with_error(message)
250
+ warn message
251
+ exit 1
252
+ end
253
+ end
254
+
255
+ def self.new(options, config)
256
+ Orchestrator.new(options, config)
257
+ end
258
+ end
259
+ end
260
+ end
@@ -38,11 +38,6 @@ module GitFit
38
38
  return
39
39
  end
40
40
 
41
- if source == 'garmin'
42
- say_status :warn, 'garmin (intl) DI auth migration pending (Phase 3)', :yellow
43
- return
44
- end
45
-
46
41
  config = git_fit_config.sync_config(source)
47
42
  if config.empty?
48
43
  say_status :warn, "No config for #{source}. Set env vars or config.yml", :yellow
@@ -82,7 +77,9 @@ module GitFit
82
77
  klass = GitFit::Sync::Base.adapters.find { |a| a.config_key == source }
83
78
  return nil unless klass
84
79
 
85
- klass.new(config: git_fit_config.sync_config(source), db: nil)
80
+ cfg = git_fit_config.sync_config(source).dup
81
+ cfg['config_path'] = options[:config] if options[:config]
82
+ klass.new(config: cfg, db: nil)
86
83
  end
87
84
  end
88
85
  end
data/lib/git_fit/cli.rb CHANGED
@@ -73,7 +73,15 @@ module GitFit
73
73
  end
74
74
 
75
75
  desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn)'
76
+ option :A, type: :boolean, desc: 'Garmin Strategy A: curl-impersonate shellout'
77
+ option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright (default)'
78
+ option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A'
79
+ option :sync, type: :boolean, desc: 'Garmin: promote cached token to auth_seed'
76
80
  def auth(source)
81
+ if source == 'garmin'
82
+ GitFit::Auth::Garmin.new(options, git_fit_config).call
83
+ return
84
+ end
77
85
  if source == 'strava'
78
86
  GitFit::Auth::Strava.new(options, git_fit_config).call
79
87
  else
@@ -23,7 +23,7 @@ module GitFit
23
23
  # garmin: # env: GIT_FIT_GARMIN_EMAIL
24
24
  # email: "" # env: GIT_FIT_GARMIN_PASSWORD
25
25
  # password: ""
26
- # auth_seed: "" # git fit auth garmin (DI OAuth2, Phase 3)
26
+ # auth_seed: "" # auto-written by git fit auth garmin
27
27
  # secret: "" # CN SSO secret (auto-written by git fit auth garmin_cn)
28
28
 
29
29
  # garmin_cn: # env: GIT_FIT_GARMIN_CN_EMAIL
@@ -519,7 +519,7 @@ module GitFit
519
519
  return nil unless m
520
520
  num = m[1].to_f
521
521
  unit = m[2].downcase
522
- if unit.start_with?('degf') || unit.start_with?('°f')
522
+ if unit.start_with?('degf', '°f')
523
523
  ((num - 32) * 5.0 / 9.0).round(1)
524
524
  else
525
525
  num.round(1)
@@ -543,17 +543,17 @@ module GitFit
543
543
 
544
544
  def map_sport_type(workout_type)
545
545
  return 'other' unless workout_type
546
- key = workout_type.sub(/\AHKWorkoutActivityType/, '')
546
+ key = workout_type.delete_prefix('HKWorkoutActivityType')
547
547
  return 'workout' if key.downcase.include?('strength')
548
548
  GitFit::SportMapper.canonicalize(key)
549
549
  end
550
550
 
551
551
  def display_name(workout_type, _start_date)
552
- workout_type.sub(/\AHKWorkoutActivityType/, '')
552
+ workout_type.delete_prefix('HKWorkoutActivityType')
553
553
  end
554
554
 
555
555
  def build_title(workout_type, start_date)
556
- label = workout_type.sub(/\AHKWorkoutActivityType/, '')
556
+ label = workout_type.delete_prefix('HKWorkoutActivityType')
557
557
  date = start_date ? start_date[0..9] : nil
558
558
  date ? "#{date} · #{label}" : label
559
559
  end
@@ -186,7 +186,7 @@ module GitFit
186
186
  end
187
187
 
188
188
  def save_secret_to_config
189
- path = 'config/config.yml'
189
+ path = @config['config_path'] || 'config/config.yml'
190
190
  return unless File.exist?(path)
191
191
 
192
192
  cfg = YAML.safe_load(File.read(path)) || {}
@@ -46,15 +46,15 @@ module GitFit
46
46
  end
47
47
 
48
48
  puts 'Garmin: no valid DI token found'
49
- puts ' First time: ./scripts/garmin_auth_local.sh'
50
- puts ' Refresh: ./scripts/garmin_auth_local.sh --sync'
49
+ puts ' First time: git fit auth garmin'
50
+ puts ' Refresh: git fit auth garmin --sync'
51
51
  false
52
52
  end
53
53
 
54
54
  def before_call
55
55
  if @config['auth_seed'].to_s.empty? && !File.exist?(token_path)
56
56
  puts 'Garmin: auth_seed not configured and no cached token'
57
- puts ' Run: ./scripts/garmin_auth_local.sh'
57
+ puts ' Run: git fit auth garmin'
58
58
  return false
59
59
  end
60
60
  @start_time = Time.now
@@ -62,7 +62,7 @@ module GitFit
62
62
  end
63
63
 
64
64
  def reauth_hint
65
- 'Re-auth: ./scripts/garmin_auth_local.sh --sync'
65
+ 'Re-auth: git fit auth garmin --sync'
66
66
  end
67
67
 
68
68
  private
@@ -110,7 +110,7 @@ module GitFit
110
110
  true
111
111
  rescue JSON::ParserError
112
112
  puts 'Garmin: auth_seed decode failed — invalid Base64 or JSON'
113
- puts ' Fix: ./scripts/garmin_auth_local.sh --sync'
113
+ puts ' Fix: git fit auth garmin --sync'
114
114
  false
115
115
  rescue StandardError => e
116
116
  puts "Garmin: auth_seed error: #{e.message}"
@@ -140,10 +140,10 @@ module GitFit
140
140
  msg = "DI refresh error: #{resp.code}"
141
141
  if resp.code.to_i == 400
142
142
  msg += ' — refresh_token expired across rotation'
143
- msg += ', re-run: scripts/garmin_auth_local.sh'
143
+ msg += ', re-run: git fit auth garmin'
144
144
  elsif resp.code.to_i == 401
145
145
  msg += ' — DI client_id rotated by Garmin'
146
- msg += ', re-run: scripts/garmin_auth_local.sh'
146
+ msg += ', re-run: git fit auth garmin'
147
147
  end
148
148
  raise AuthError, msg
149
149
  end
@@ -161,10 +161,10 @@ module GitFit
161
161
  # @deprecated Kept for local script reference only.
162
162
  # email+password SSO strategies are not called by `authenticate`
163
163
  # because garmin.com DI auth requires MFA, handled by
164
- # scripts/garmin_auth_local.sh outside Ruby.
164
+ # git fit auth garmin outside Ruby.
165
165
  #
166
166
  # Note: method naming (strategy_a = curl_cffi, strategy_b = Playwright) is
167
- # historical. In scripts/garmin_auth_local.sh, Playwright runs first (cookies
167
+ # historical. In git fit auth garmin, Playwright runs first (cookies
168
168
  # persist 365d → no repeat MFA), curl_cffi is the fallback.
169
169
  def strategy_a_login
170
170
  script = File.expand_path('../../../scripts/garmin_auth.py', __dir__)
@@ -41,7 +41,7 @@ module GitFit
41
41
  start_watchdog if @total_timeout
42
42
  init_pending
43
43
  unless @source_queue.empty?
44
- worker_threads = @num_workers.times.map { Thread.new { worker_loop } }
44
+ worker_threads = Array.new(@num_workers) { Thread.new { worker_loop } }
45
45
  worker_threads.each(&:join)
46
46
  end
47
47
  output_queue << POISON
@@ -321,9 +321,9 @@ module GitFit
321
321
  case msg[:type]
322
322
  when :banner
323
323
  text = msg[:text]
324
- if text =~ /auth failed/
324
+ if /auth failed/.match?(text)
325
325
  ps[:auth_failed] = true
326
- elsif text =~ /no new activities/
326
+ elsif /no new activities/.match?(text)
327
327
  ps[:up_to_date] = true
328
328
  elsif text =~ /(\d+) pending/
329
329
  ps[:total] = $1.to_i
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.10.6'
4
+ VERSION = '0.10.7'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-fit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.6
4
+ version: 0.10.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -191,6 +191,20 @@ dependencies:
191
191
  - - "~>"
192
192
  - !ruby/object:Gem::Version
193
193
  version: '1.9'
194
+ - !ruby/object:Gem::Dependency
195
+ name: playwright-ruby-client
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: '1.0'
201
+ type: :runtime
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: '1.0'
194
208
  - !ruby/object:Gem::Dependency
195
209
  name: ostruct
196
210
  requirement: !ruby/object:Gem::Requirement
@@ -232,6 +246,10 @@ files:
232
246
  - db/migrations/002_iso8601_time_format.rb
233
247
  - exe/git-fit
234
248
  - lib/git-fit.rb
249
+ - lib/git_fit/auth/garmin.rb
250
+ - lib/git_fit/auth/garmin/di_exchange.rb
251
+ - lib/git_fit/auth/garmin/strategy_a.rb
252
+ - lib/git_fit/auth/garmin/strategy_b.rb
235
253
  - lib/git_fit/auth/garmin_token.rb
236
254
  - lib/git_fit/auth/strava.rb
237
255
  - lib/git_fit/cli.rb