freshbooks-cli 0.3.2 → 0.4.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.
data/lib/fb/auth.rb DELETED
@@ -1,379 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "httparty"
4
- require "json"
5
- require "uri"
6
- require "fileutils"
7
-
8
- module FB
9
- class Auth
10
- TOKEN_URL = "https://api.freshbooks.com/auth/oauth/token"
11
- AUTH_URL = "https://auth.freshbooks.com/oauth/authorize"
12
- ME_URL = "https://api.freshbooks.com/auth/api/v1/users/me"
13
- REDIRECT_URI = "https://localhost"
14
- REQUIRED_SCOPES = %w[
15
- user:profile:read
16
- user:clients:read
17
- user:projects:read
18
- user:billable_items:read
19
- user:time_entries:read
20
- user:time_entries:write
21
- ].freeze
22
-
23
- class << self
24
- def data_dir
25
- @data_dir ||= File.join(Dir.home, ".fb")
26
- end
27
-
28
- def data_dir=(path)
29
- @data_dir = path
30
- end
31
-
32
- def config_path
33
- File.join(data_dir, "config.json")
34
- end
35
-
36
- def tokens_path
37
- File.join(data_dir, "tokens.json")
38
- end
39
-
40
- def defaults_path
41
- File.join(data_dir, "defaults.json")
42
- end
43
-
44
- def cache_path
45
- File.join(data_dir, "cache.json")
46
- end
47
-
48
- def ensure_data_dir
49
- FileUtils.mkdir_p(data_dir)
50
- end
51
-
52
- # --- Config ---
53
-
54
- def load_config
55
- return nil unless File.exist?(config_path)
56
- contents = File.read(config_path).strip
57
- return nil if contents.empty?
58
- config = JSON.parse(contents)
59
- return nil unless config["client_id"] && config["client_secret"]
60
- config
61
- rescue JSON::ParserError
62
- nil
63
- end
64
-
65
- def save_config(config)
66
- ensure_data_dir
67
- File.write(config_path, JSON.pretty_generate(config) + "\n")
68
- end
69
-
70
- def setup_config
71
- puts "Welcome to FreshBooks CLI setup!\n\n"
72
- puts "You need a FreshBooks Developer App. Create one at:"
73
- puts " https://my.freshbooks.com/#/developer\n\n"
74
- puts "Set the redirect URI to: #{REDIRECT_URI}\n\n"
75
- puts "Required scopes:"
76
- puts " user:profile:read (enabled by default)"
77
- puts " user:clients:read"
78
- puts " user:projects:read"
79
- puts " user:billable_items:read"
80
- puts " user:time_entries:read"
81
- puts " user:time_entries:write\n\n"
82
-
83
- print "Client ID: "
84
- client_id = $stdin.gets&.strip
85
- abort("Aborted.") if client_id.nil? || client_id.empty?
86
-
87
- print "Client Secret: "
88
- client_secret = $stdin.gets&.strip
89
- abort("Aborted.") if client_secret.nil? || client_secret.empty?
90
-
91
- config = { "client_id" => client_id, "client_secret" => client_secret }
92
- save_config(config)
93
- puts "\nConfig saved to #{config_path}"
94
- config
95
- end
96
-
97
- def setup_config_from_args(client_id, client_secret)
98
- abort("Missing --client-id") if client_id.nil? || client_id.empty?
99
- abort("Missing --client-secret") if client_secret.nil? || client_secret.empty?
100
-
101
- config = { "client_id" => client_id, "client_secret" => client_secret }
102
- save_config(config)
103
- config
104
- end
105
-
106
- def authorize_url(config)
107
- "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
108
- end
109
-
110
- def extract_code_from_url(redirect_url)
111
- uri = URI.parse(redirect_url)
112
- params = URI.decode_www_form(uri.query || "").to_h
113
- params["code"]
114
- end
115
-
116
- def auth_status
117
- config = load_config
118
- tokens = load_tokens
119
- {
120
- "config_exists" => !config.nil?,
121
- "config_path" => config_path,
122
- "tokens_exist" => !tokens.nil?,
123
- "tokens_expired" => tokens ? token_expired?(tokens) : nil,
124
- "business_id" => config&.dig("business_id"),
125
- "account_id" => config&.dig("account_id")
126
- }
127
- end
128
-
129
- def fetch_businesses(access_token)
130
- identity = fetch_identity(access_token)
131
- memberships = identity.dig("business_memberships") || []
132
- memberships.select { |m| m.dig("business", "account_id") }
133
- end
134
-
135
- def select_business(config, business_id, businesses)
136
- selected = businesses.find { |m| m.dig("business", "id").to_s == business_id.to_s }
137
- abort("Business not found: #{business_id}. Available: #{businesses.map { |m| "#{m.dig("business", "name")} (#{m.dig("business", "id")})" }.join(", ")}") unless selected
138
-
139
- biz = selected["business"]
140
- config["business_id"] = biz["id"]
141
- config["account_id"] = biz["account_id"]
142
- save_config(config)
143
- config
144
- end
145
-
146
- def require_config
147
- config = load_config
148
- return config if config
149
-
150
- puts "No config found. Let's set up FreshBooks CLI.\n\n"
151
- setup_config
152
- end
153
-
154
- # --- Tokens ---
155
-
156
- def load_tokens
157
- return nil unless File.exist?(tokens_path)
158
- JSON.parse(File.read(tokens_path))
159
- end
160
-
161
- def save_tokens(tokens)
162
- ensure_data_dir
163
- File.write(tokens_path, JSON.pretty_generate(tokens) + "\n")
164
- end
165
-
166
- def token_expired?(tokens)
167
- return true unless tokens
168
- created = tokens["created_at"] || 0
169
- expires_in = tokens["expires_in"] || 0
170
- Time.now.to_i >= (created + expires_in - 60)
171
- end
172
-
173
- def refresh_token!(config, tokens)
174
- response = HTTParty.post(TOKEN_URL, {
175
- headers: { "Content-Type" => "application/json" },
176
- body: {
177
- grant_type: "refresh_token",
178
- client_id: config["client_id"],
179
- client_secret: config["client_secret"],
180
- redirect_uri: REDIRECT_URI,
181
- refresh_token: tokens["refresh_token"]
182
- }.to_json
183
- })
184
-
185
- unless response.success?
186
- body = response.parsed_response
187
- msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
188
- abort("Token refresh failed: #{msg}\nPlease re-run: fb auth")
189
- end
190
-
191
- data = response.parsed_response
192
- new_tokens = {
193
- "access_token" => data["access_token"],
194
- "refresh_token" => data["refresh_token"],
195
- "expires_in" => data["expires_in"],
196
- "created_at" => Time.now.to_i
197
- }
198
- save_tokens(new_tokens)
199
- new_tokens
200
- end
201
-
202
- def valid_access_token
203
- config = require_config
204
- tokens = load_tokens
205
-
206
- unless tokens
207
- puts "Not authenticated yet. Starting auth flow...\n\n"
208
- tokens = authorize(config)
209
- discover_business(tokens["access_token"], config)
210
- end
211
-
212
- if token_expired?(tokens)
213
- puts "Token expired, refreshing..."
214
- tokens = refresh_token!(config, tokens)
215
- end
216
-
217
- tokens["access_token"]
218
- end
219
-
220
- def require_business(config)
221
- return config if config["business_id"] && config["account_id"]
222
-
223
- tokens = load_tokens
224
- unless tokens
225
- puts "Not authenticated yet. Starting auth flow...\n\n"
226
- tokens = authorize(config)
227
- end
228
-
229
- discover_business(tokens["access_token"], config)
230
- end
231
-
232
- # --- OAuth Flow ---
233
-
234
- def authorize(config)
235
- url = "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
236
-
237
- puts "Open this URL in your browser:\n\n"
238
- puts " #{url}\n\n"
239
- puts "After authorizing, you'll be redirected to a URL that fails to load."
240
- puts "Copy the full URL from your browser's address bar and paste it here.\n\n"
241
-
242
- print "Redirect URL: "
243
- redirect_url = $stdin.gets&.strip
244
- abort("Aborted.") if redirect_url.nil? || redirect_url.empty?
245
-
246
- uri = URI.parse(redirect_url)
247
- params = URI.decode_www_form(uri.query || "").to_h
248
- code = params["code"]
249
-
250
- abort("Could not find 'code' parameter in the URL.") unless code
251
-
252
- exchange_code(config, code)
253
- end
254
-
255
- def exchange_code(config, code)
256
- response = HTTParty.post(TOKEN_URL, {
257
- headers: { "Content-Type" => "application/json" },
258
- body: {
259
- grant_type: "authorization_code",
260
- client_id: config["client_id"],
261
- client_secret: config["client_secret"],
262
- redirect_uri: REDIRECT_URI,
263
- code: code
264
- }.to_json
265
- })
266
-
267
- unless response.success?
268
- body = response.parsed_response
269
- msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
270
- abort("Token exchange failed: #{msg}")
271
- end
272
-
273
- data = response.parsed_response
274
- check_scopes(data["scope"])
275
-
276
- tokens = {
277
- "access_token" => data["access_token"],
278
- "refresh_token" => data["refresh_token"],
279
- "expires_in" => data["expires_in"],
280
- "created_at" => Time.now.to_i
281
- }
282
- save_tokens(tokens)
283
- puts "Authentication successful!"
284
- tokens
285
- end
286
-
287
- def check_scopes(granted_scope)
288
- return if granted_scope.nil? # skip check if API doesn't return scopes
289
-
290
- granted = granted_scope.split(" ")
291
- missing = REQUIRED_SCOPES - granted
292
-
293
- return if missing.empty?
294
-
295
- puts "ERROR: Your FreshBooks app is missing required scopes:\n\n"
296
- missing.each { |s| puts " - #{s}" }
297
- puts "\nAdd them at https://my.freshbooks.com/#/developer"
298
- puts "then re-run: fb auth"
299
- abort
300
- end
301
-
302
- # --- Business Discovery ---
303
-
304
- def fetch_identity(access_token)
305
- response = HTTParty.get(ME_URL, {
306
- headers: { "Authorization" => "Bearer #{access_token}" }
307
- })
308
-
309
- unless response.success?
310
- abort("Failed to fetch user identity: #{response.body}")
311
- end
312
-
313
- response.parsed_response["response"]
314
- end
315
-
316
- def discover_business(access_token, config)
317
- identity = fetch_identity(access_token)
318
- memberships = identity.dig("business_memberships") || []
319
- businesses = memberships.select { |m| m.dig("business", "account_id") }
320
-
321
- if businesses.empty?
322
- abort("No business memberships found on your FreshBooks account.")
323
- end
324
-
325
- selected = if businesses.length == 1
326
- businesses.first
327
- else
328
- puts "\nMultiple businesses found:\n\n"
329
- businesses.each_with_index do |m, i|
330
- biz = m["business"]
331
- puts " #{i + 1}. #{biz["name"]} (ID: #{biz["id"]})"
332
- end
333
- print "\nSelect a business (1-#{businesses.length}): "
334
- choice = $stdin.gets&.strip&.to_i || 1
335
- choice = 1 if choice < 1 || choice > businesses.length
336
- businesses[choice - 1]
337
- end
338
-
339
- biz = selected["business"]
340
- config["business_id"] = biz["id"]
341
- config["account_id"] = biz["account_id"]
342
- save_config(config)
343
-
344
- puts "Business: #{biz["name"]}"
345
- puts " business_id: #{biz["id"]}"
346
- puts " account_id: #{biz["account_id"]}"
347
- config
348
- end
349
-
350
- # --- Defaults ---
351
-
352
- def load_defaults
353
- return {} unless File.exist?(defaults_path)
354
- JSON.parse(File.read(defaults_path))
355
- rescue JSON::ParserError
356
- {}
357
- end
358
-
359
- def save_defaults(defaults)
360
- ensure_data_dir
361
- File.write(defaults_path, JSON.pretty_generate(defaults) + "\n")
362
- end
363
-
364
- # --- Cache ---
365
-
366
- def load_cache
367
- return {} unless File.exist?(cache_path)
368
- JSON.parse(File.read(cache_path))
369
- rescue JSON::ParserError
370
- {}
371
- end
372
-
373
- def save_cache(cache)
374
- ensure_data_dir
375
- File.write(cache_path, JSON.pretty_generate(cache) + "\n")
376
- end
377
- end
378
- end
379
- end