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 +4 -4
- data/lib/git-fit.rb +1 -0
- data/lib/git_fit/auth/garmin/di_exchange.rb +86 -0
- data/lib/git_fit/auth/garmin/strategy_a.rb +332 -0
- data/lib/git_fit/auth/garmin/strategy_b.rb +215 -0
- data/lib/git_fit/auth/garmin.rb +260 -0
- data/lib/git_fit/cli/sync.rb +3 -6
- data/lib/git_fit/cli.rb +8 -0
- data/lib/git_fit/config_template.rb +1 -1
- data/lib/git_fit/import/apple_health.rb +4 -4
- data/lib/git_fit/sync/garmin_base.rb +1 -1
- data/lib/git_fit/sync/garmin_base_di.rb +9 -9
- data/lib/git_fit/sync/runner.rb +3 -3
- data/lib/git_fit/version.rb +1 -1
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32616b46febca2fefadfe46d783de032676821f74e82c0a65e85f3a0def488f1
|
|
4
|
+
data.tar.gz: 4b409c0d20c049c50e804123d82639915e35b6df957c1543756e75e4a2d8d643
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 39a504dad06283e1a891552b775d0df401f2a038343323e1084379882739a9fe669841b7195c8fa28d0bea136ac62b8e61cccc5c1eac9fb3ce4aca800d156a69
|
|
7
|
+
data.tar.gz: dfbb3028468e96f6b943d332856e27520b034d216504bbd6072e26e656700d9340b38991e83fc6b5d869cbdc9a745c2a1fa5e684c903425b18dc45e608eacfa9
|
data/lib/git-fit.rb
CHANGED
|
@@ -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
|
data/lib/git_fit/cli/sync.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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'
|
|
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.
|
|
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.
|
|
552
|
+
workout_type.delete_prefix('HKWorkoutActivityType')
|
|
553
553
|
end
|
|
554
554
|
|
|
555
555
|
def build_title(workout_type, start_date)
|
|
556
|
-
label = workout_type.
|
|
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
|
|
@@ -46,15 +46,15 @@ module GitFit
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
puts 'Garmin: no valid DI token found'
|
|
49
|
-
puts ' First time:
|
|
50
|
-
puts ' Refresh:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
#
|
|
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
|
|
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__)
|
data/lib/git_fit/sync/runner.rb
CHANGED
|
@@ -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
|
|
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
|
|
324
|
+
if /auth failed/.match?(text)
|
|
325
325
|
ps[:auth_failed] = true
|
|
326
|
-
elsif
|
|
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
|
data/lib/git_fit/version.rb
CHANGED
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.
|
|
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
|